@gleapai/kai-bridge 0.10.2 → 0.12.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 +19 -4
- package/bin/kai-bridge.mjs +116 -60
- package/npm-shrinkwrap.json +2 -14
- package/package.json +4 -2
- package/src/api.mjs +10 -0
- package/src/config.mjs +1 -0
- package/src/daemon.mjs +118 -7
- package/src/deps.mjs +26 -5
- package/src/help.mjs +115 -0
- package/src/logs.mjs +62 -0
- package/src/ps.mjs +1 -1
- package/src/service.mjs +1 -1
- package/src/setup.mjs +412 -129
- package/src/tui.mjs +76 -0
- package/src/workspace.mjs +75 -21
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Claude Code / Codex login, and local dev-server previews.
|
|
|
6
6
|
```bash
|
|
7
7
|
npm i -g @gleapai/kai-bridge # one install, so the background service has a stable path
|
|
8
8
|
kai-bridge login # pair this machine with your Gleap account
|
|
9
|
-
kai-bridge install
|
|
9
|
+
kai-bridge service install # run at login (launchd / systemd / Task Scheduler)
|
|
10
10
|
kai-bridge status
|
|
11
11
|
kai-bridge ps # sessions running on this machine, with dashboard links (--json for tooling)
|
|
12
12
|
```
|
|
@@ -44,23 +44,38 @@ kai-bridge run --task "Explain the auth flow in this repo" --cwd ~/code/my-app
|
|
|
44
44
|
`npm run sync-runner` copies the ACP runner from a sibling `gleap_code_analyzer` checkout;
|
|
45
45
|
`npm test` runs the unit suite (uses real `git`).
|
|
46
46
|
|
|
47
|
+
### Trying CLI changes locally
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm run cli -- status # any kai-bridge command, run from this checkout
|
|
51
|
+
npm run cli -- login --name "dev laptop"
|
|
52
|
+
npm run cli -- start # foreground daemon; Ctrl-C stops it
|
|
53
|
+
npm run test:cli # fast CLI regression suite (test/cli.test.mjs)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`npm run cli` (scripts/dev-cli.mjs) uses `<repo>/.kai-dev` as `KAI_HOME` and turns
|
|
57
|
+
self-update off, so it never touches `~/.kai` or the background service running the
|
|
58
|
+
released version. Export `KAI_API_BASE` / `KAI_APP_BASE` to point it at a local server.
|
|
59
|
+
Don't `npm link` the package: the login service runs the global install by absolute
|
|
60
|
+
path, and a link would swap that for this checkout.
|
|
61
|
+
|
|
47
62
|
|
|
48
63
|
### Computers and your own servers
|
|
49
64
|
|
|
50
65
|
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).
|
|
51
66
|
|
|
52
|
-
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
|
|
67
|
+
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 service status` and `kai-bridge service logs --follow` show what it is doing.
|
|
53
68
|
|
|
54
69
|
Codex and Claude Code ship with Kai Code Bridge. Missing agents can be installed or repaired, and existing agents can be updated independently:
|
|
55
70
|
|
|
56
71
|
```sh
|
|
57
72
|
kai-bridge harness install codex
|
|
58
73
|
kai-bridge harness update codex
|
|
59
|
-
kai-bridge
|
|
74
|
+
kai-bridge profile login codex --device-auth
|
|
60
75
|
|
|
61
76
|
kai-bridge harness install claude
|
|
62
77
|
kai-bridge harness update claude
|
|
63
|
-
kai-bridge
|
|
78
|
+
kai-bridge profile login claude
|
|
64
79
|
```
|
|
65
80
|
|
|
66
81
|
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).
|
package/bin/kai-bridge.mjs
CHANGED
|
@@ -1,23 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// kai-bridge — run Gleap Kai Code on this machine.
|
|
3
|
-
//
|
|
4
|
-
// kai-bridge setup guided onboarding: pair · background service · harness sign-ins
|
|
5
|
-
// (a bare `kai-bridge` on an unpaired machine runs it too)
|
|
6
|
-
// kai-bridge login | logout pair this device with your Gleap account / disconnect it
|
|
7
|
-
// kai-bridge install | uninstall run at login (launchd / systemd / Task Scheduler)
|
|
8
|
-
// kai-bridge start run in the foreground (what the service runs)
|
|
9
|
-
// kai-bridge status device, service, profiles, repos
|
|
10
|
-
// kai-bridge ps [--json] what runs right now: daemon, turns, dev servers — with dashboard links
|
|
11
|
-
// kai-bridge harness list|install <id>|update <id>|login <id> [--device-auth] Claude Code + Codex are bundled; Cursor is downloaded
|
|
12
|
-
// kai-bridge profile list|add <id> --harness claude|codex|cursor [--label …]|login <id>|remove <id>
|
|
13
|
-
// kai-bridge repo scan|roots [add <dir>|remove <dir>]|primary <repoKey> <path>
|
|
14
|
-
// kai-bridge run --task "…" [--model …] [--profile …] [--cwd …] (local turn, no server)
|
|
15
|
-
// kai-bridge doctor
|
|
3
|
+
// The command reference lives in src/help.mjs (`kai-bridge help`).
|
|
16
4
|
|
|
17
5
|
import { spawn } from "node:child_process";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { platform } from "node:os";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
21
8
|
import { fileURLToPath } from "node:url";
|
|
22
9
|
|
|
23
10
|
import { KAI_HOME, loadConfig, saveConfig } from "../src/config.mjs";
|
|
@@ -26,13 +13,38 @@ import { BridgeDaemon, createLogger, resolveProfiles } from "../src/daemon.mjs";
|
|
|
26
13
|
import { runTurn } from "../src/executor.mjs";
|
|
27
14
|
import { HARNESSES, createManagedProfile, describeProfiles, findBinary, loginCommand } from "../src/profiles.mjs";
|
|
28
15
|
import { defaultRoots, groupByRepo, scanRoots, toDeviceRepoReport } from "../src/repos.mjs";
|
|
29
|
-
import { install, isInstalled, uninstall, isEphemeralBinPath } from "../src/service.mjs";
|
|
16
|
+
import { install, isInstalled, uninstall, isEphemeralBinPath, servicePaths } from "../src/service.mjs";
|
|
17
|
+
import { followFile, tailFile } from "../src/logs.mjs";
|
|
30
18
|
import { HARNESS_INFO, describeHarnesses, installHarness } from "../src/harnesses.mjs";
|
|
31
19
|
import { fetchLatestVersion, installVersion, installedVersion, isNewer } from "../src/selfupdate.mjs";
|
|
32
|
-
import { collectProcessList, formatProcessList } from "../src/ps.mjs";
|
|
20
|
+
import { collectProcessList, formatAge, formatProcessList } from "../src/ps.mjs";
|
|
21
|
+
import { renderCommandHelp, renderHelp, suggestCommand } from "../src/help.mjs";
|
|
22
|
+
import { palette } from "../src/tui.mjs";
|
|
33
23
|
|
|
34
24
|
const BIN = fileURLToPath(import.meta.url);
|
|
25
|
+
// `npm i -g` only warns on an old Node; the first run would die with a
|
|
26
|
+
// SyntaxError instead. Say what is needed.
|
|
27
|
+
{
|
|
28
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
29
|
+
if (major < 20) {
|
|
30
|
+
process.stderr.write(`error: kai-bridge needs Node.js 20 or newer (this is ${process.versions.node}). Install a current Node from https://nodejs.org and run it again.\n`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// `kai-bridge help | head` closes our stdout early: end quietly, not with a stack.
|
|
35
|
+
process.stdout.on("error", (err) => {
|
|
36
|
+
if (err?.code === "EPIPE") process.exit(0);
|
|
37
|
+
throw err;
|
|
38
|
+
});
|
|
35
39
|
const argv = process.argv.slice(2);
|
|
40
|
+
// `--version`/`-v` and `--help`/`-h` are flags people type before or after the
|
|
41
|
+
// command; normalise them into the `version` and `help` commands.
|
|
42
|
+
if (argv.includes("--version") || argv.includes("-v") || argv[0] === "version") {
|
|
43
|
+
process.stdout.write(`${installedVersion()}\n`);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
if (argv[0] === "--help" || argv[0] === "-h") argv[0] = "help";
|
|
47
|
+
else if (argv.includes("--help") || argv.includes("-h")) argv.splice(0, argv.length, "help", argv[0]);
|
|
36
48
|
const cmd = argv[0];
|
|
37
49
|
const flags = {};
|
|
38
50
|
const positional = [];
|
|
@@ -52,14 +64,18 @@ const fail = (s) => {
|
|
|
52
64
|
async function login() {
|
|
53
65
|
const config = loadConfig();
|
|
54
66
|
await pairDevice({ config, name: flags.name, print: out });
|
|
55
|
-
out(`Next: \`kai-bridge install\` keeps it running at login
|
|
67
|
+
out(`Next: \`kai-bridge service install\` keeps it running at login (\`kai-bridge start\` runs it right now), then \`kai-bridge profile login claude\` (or codex/cursor) to sign in your coding agent. \`kai-bridge\` does all of this in one go.`);
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
async function status() {
|
|
59
71
|
const config = loadConfig();
|
|
72
|
+
out(`node: ${process.version} (${process.execPath})`);
|
|
73
|
+
out(`git: ${findBinary("git") ?? "not found"}`);
|
|
74
|
+
out(`claude: ${findBinary("claude") ?? "not on PATH (bundled copy is used)"}`);
|
|
75
|
+
out(`codex: ${findBinary("codex") ?? "not on PATH (bundled copy is used)"}`);
|
|
60
76
|
out(`home: ${KAI_HOME}`);
|
|
61
77
|
out(`device: ${config.device ? `${config.device.name} (${config.device.id})` : "not paired — run `kai-bridge login`"}`);
|
|
62
|
-
out(`service: ${isInstalled() ? "installed" : "not installed — run `kai-bridge install`"}`);
|
|
78
|
+
out(`service: ${isInstalled() ? "installed" : "not installed — run `kai-bridge service install`"}`);
|
|
63
79
|
out("harnesses:");
|
|
64
80
|
for (const h of describeHarnesses(KAI_HOME)) out(` ${h.id.padEnd(8)} ${h.installed ? `installed ${h.version ?? ""}` : "not installed — kai-bridge harness install " + h.id}${h.bundled ? " (bundled)" : ""}`);
|
|
65
81
|
const profiles = await describeProfiles(resolveProfiles(config));
|
|
@@ -95,8 +111,10 @@ async function profile() {
|
|
|
95
111
|
return;
|
|
96
112
|
}
|
|
97
113
|
if (sub === "login") {
|
|
98
|
-
|
|
99
|
-
|
|
114
|
+
// A harness name means the machine's own login for it.
|
|
115
|
+
const wanted = HARNESS_INFO[positional[1]] ? `${positional[1]}-ambient` : positional[1];
|
|
116
|
+
const p = resolveProfiles(config).find((x) => x.id === wanted);
|
|
117
|
+
if (!p) fail(`unknown profile ${positional[1] ?? ""} — \`kai-bridge profile list\` shows them; \`profile login claude|codex|cursor\` signs in the machine's own agent.`);
|
|
100
118
|
await installHarness(p.harness, { kaiHome: KAI_HOME, onLog: out });
|
|
101
119
|
const c = loginCommand(p.harness, p.configDir, KAI_HOME, flags["device-auth"] ? { deviceAuth: true } : undefined);
|
|
102
120
|
if (!c) fail(`${p.harness} is not installed`);
|
|
@@ -110,7 +128,7 @@ async function profile() {
|
|
|
110
128
|
out("removed (the login dir is kept under ~/.kai/accounts)");
|
|
111
129
|
return;
|
|
112
130
|
}
|
|
113
|
-
fail("usage: profile list|add|
|
|
131
|
+
fail("usage: profile list | login <id> [--device-auth] | add <id> --harness <claude|codex|cursor> [--label …] | remove <id>");
|
|
114
132
|
}
|
|
115
133
|
|
|
116
134
|
async function harness() {
|
|
@@ -120,25 +138,22 @@ async function harness() {
|
|
|
120
138
|
return;
|
|
121
139
|
}
|
|
122
140
|
const id = positional[1];
|
|
123
|
-
if (!HARNESS_INFO[id]) fail("usage: harness list|install <id
|
|
141
|
+
if (!HARNESS_INFO[id]) fail("usage: harness list | install <id> | update <id> (claude|codex|cursor — sign in with `profile login <id>`)");
|
|
124
142
|
if (sub === "install" || sub === "update") {
|
|
125
143
|
const res = await installHarness(id, { kaiHome: KAI_HOME, onLog: out, update: sub === "update", version: typeof flags.version === "string" ? flags.version : undefined });
|
|
126
144
|
if (!res.ok) fail(`${id} is not usable`);
|
|
127
145
|
out(`${HARNESS_INFO[id].label} ${res.version} → ${res.binary}`);
|
|
128
146
|
return;
|
|
129
147
|
}
|
|
130
|
-
if (sub === "login")
|
|
131
|
-
|
|
132
|
-
positional[0] = "login";
|
|
133
|
-
return profile();
|
|
134
|
-
}
|
|
135
|
-
fail("usage: harness list|install <id>|update <id>|login <id> [--device-auth]");
|
|
148
|
+
if (sub === "login") return profile(); // old spelling of `profile login <id>`
|
|
149
|
+
fail("usage: harness list | install <id> | update <id>");
|
|
136
150
|
}
|
|
137
151
|
|
|
138
152
|
async function repo() {
|
|
139
153
|
const config = loadConfig();
|
|
140
|
-
const sub = positional[0] || "
|
|
154
|
+
const sub = positional[0] || "roots";
|
|
141
155
|
if (sub === "scan") {
|
|
156
|
+
// Undocumented: `status` shows the same list.
|
|
142
157
|
const groups = groupByRepo(scanRoots([...defaultRoots(), ...(config.roots || [])]), config.primaryOverrides);
|
|
143
158
|
for (const r of toDeviceRepoReport(groups)) out(`${r.key}\t${r.primaryPath}\t${r.branch}`);
|
|
144
159
|
return;
|
|
@@ -160,7 +175,7 @@ async function repo() {
|
|
|
160
175
|
out("ok");
|
|
161
176
|
return;
|
|
162
177
|
}
|
|
163
|
-
fail("usage: repo
|
|
178
|
+
fail("usage: repo roots [add <dir> | remove <dir>] | primary <repoKey> <path> (`kai-bridge status` lists the checkouts)");
|
|
164
179
|
}
|
|
165
180
|
|
|
166
181
|
async function runLocal() {
|
|
@@ -187,13 +202,54 @@ async function runLocal() {
|
|
|
187
202
|
process.exit(res.code ?? 0);
|
|
188
203
|
}
|
|
189
204
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
205
|
+
/** `kai-bridge service …` — the login service and its logs. */
|
|
206
|
+
async function service() {
|
|
207
|
+
const sub = positional[0] || "status";
|
|
208
|
+
if (sub === "install") {
|
|
209
|
+
if (isEphemeralBinPath(BIN)) {
|
|
210
|
+
fail(
|
|
211
|
+
"Running from npx, so there's no stable path to install from.\n" +
|
|
212
|
+
"Install it once, then this works:\n\n" +
|
|
213
|
+
" npm i -g @gleapai/kai-bridge\n kai-bridge service install\n",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const res = install({ binPath: BIN, logDir: join(KAI_HOME, "logs") });
|
|
217
|
+
out(`Installed (${res.kind}): ${res.path}. It runs at login and restarts if it crashes.`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (sub === "uninstall") {
|
|
221
|
+
out(uninstall() ? "Removed." : "Nothing to remove.");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (sub === "restart") {
|
|
225
|
+
if (!isInstalled()) fail("The background service is not installed — run `kai-bridge service install`.");
|
|
226
|
+
const res = install({ binPath: BIN, logDir: join(KAI_HOME, "logs") });
|
|
227
|
+
out(`Restarted (${res.kind}).`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (sub === "status") {
|
|
231
|
+
const paths = servicePaths();
|
|
232
|
+
const unit = platform() === "darwin" ? paths.launchdPlist : platform() === "linux" ? paths.systemdUnit : "Task Scheduler: GleapKaiBridge";
|
|
233
|
+
const d = collectProcessList().daemon;
|
|
234
|
+
out(`service: ${isInstalled() ? `installed · ${unit}` : "not installed — run `kai-bridge service install`"}`);
|
|
235
|
+
out(`daemon: ${d.running ? `running · pid ${d.pid} · up ${formatAge(d.startedAt)} · v${d.version}` : `not running${d.pid ? ` (stale lock, pid ${d.pid})` : ""}`}`);
|
|
236
|
+
out(`logs: ${join(KAI_HOME, "logs", "bridge.log")}`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (sub === "logs") {
|
|
240
|
+
const file = join(KAI_HOME, "logs", flags.service ? "service.err.log" : "bridge.log");
|
|
241
|
+
const n = Number(flags.lines) || 50;
|
|
242
|
+
const last = tailFile(file, n);
|
|
243
|
+
if (last) out(last);
|
|
244
|
+
else if (!flags.follow) out(`(no log yet at ${file})`);
|
|
245
|
+
if (flags.follow) {
|
|
246
|
+
const ctrl = new AbortController();
|
|
247
|
+
process.on("SIGINT", () => ctrl.abort());
|
|
248
|
+
await followFile(file, { signal: ctrl.signal });
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
fail("usage: service install | uninstall | restart | status | logs [--lines <n>] [--follow] [--service]");
|
|
197
253
|
}
|
|
198
254
|
|
|
199
255
|
try {
|
|
@@ -207,20 +263,14 @@ try {
|
|
|
207
263
|
case "logout":
|
|
208
264
|
await logout();
|
|
209
265
|
break;
|
|
210
|
-
case "
|
|
211
|
-
|
|
212
|
-
fail(
|
|
213
|
-
"Running from npx, so there's no stable path to install from.\n" +
|
|
214
|
-
"Install it once, then this works:\n\n" +
|
|
215
|
-
" npm i -g @gleapai/kai-bridge\n kai-bridge install\n",
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
const res = install({ binPath: BIN, logDir: join(KAI_HOME, "logs") });
|
|
219
|
-
out(`Installed (${res.kind}): ${res.path}. It runs at login and restarts if it crashes.`);
|
|
266
|
+
case "service":
|
|
267
|
+
await service();
|
|
220
268
|
break;
|
|
221
|
-
|
|
269
|
+
case "install":
|
|
222
270
|
case "uninstall":
|
|
223
|
-
|
|
271
|
+
// Old spellings, kept working.
|
|
272
|
+
positional.unshift(cmd);
|
|
273
|
+
await service();
|
|
224
274
|
break;
|
|
225
275
|
case "start": {
|
|
226
276
|
process.env.KAI_BRIDGE_FOREGROUND = process.stdout.isTTY ? "1" : "0";
|
|
@@ -255,7 +305,7 @@ try {
|
|
|
255
305
|
await runLocal();
|
|
256
306
|
break;
|
|
257
307
|
case "doctor":
|
|
258
|
-
await
|
|
308
|
+
await status();
|
|
259
309
|
break;
|
|
260
310
|
case "update": {
|
|
261
311
|
// Manual update: ignores autoUpdate and a previous failed attempt.
|
|
@@ -288,14 +338,20 @@ try {
|
|
|
288
338
|
await runSetup({ binPath: BIN });
|
|
289
339
|
break;
|
|
290
340
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
341
|
+
if (cmd === "help" || !cmd) {
|
|
342
|
+
const topic = positional[0];
|
|
343
|
+
if (topic) {
|
|
344
|
+
const lines = renderCommandHelp(topic, { paint: palette() });
|
|
345
|
+
if (!lines) fail(`unknown command: ${topic}${suggestCommand(topic) ? ` (did you mean \`${suggestCommand(topic)}\`?)` : ""}\nRun \`kai-bridge help\` to list the commands.`);
|
|
346
|
+
for (const l of lines) out(l);
|
|
347
|
+
} else {
|
|
348
|
+
for (const l of renderHelp({ paint: palette(), version: installedVersion() })) out(l);
|
|
349
|
+
}
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
// Not a command at all. A close match is almost always a typo.
|
|
353
|
+
const near = suggestCommand(cmd);
|
|
354
|
+
fail(`unknown command: ${cmd}${near ? ` (did you mean \`${near}\`?)` : ""}\nRun \`kai-bridge help\` to list the commands.`);
|
|
299
355
|
}
|
|
300
356
|
}
|
|
301
357
|
} catch (err) {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleapai/kai-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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.12.0",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|
|
@@ -134,9 +134,6 @@
|
|
|
134
134
|
"cpu": [
|
|
135
135
|
"arm64"
|
|
136
136
|
],
|
|
137
|
-
"libc": [
|
|
138
|
-
"glibc"
|
|
139
|
-
],
|
|
140
137
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
141
138
|
"optional": true,
|
|
142
139
|
"os": [
|
|
@@ -150,9 +147,6 @@
|
|
|
150
147
|
"cpu": [
|
|
151
148
|
"arm64"
|
|
152
149
|
],
|
|
153
|
-
"libc": [
|
|
154
|
-
"musl"
|
|
155
|
-
],
|
|
156
150
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
157
151
|
"optional": true,
|
|
158
152
|
"os": [
|
|
@@ -166,9 +160,6 @@
|
|
|
166
160
|
"cpu": [
|
|
167
161
|
"x64"
|
|
168
162
|
],
|
|
169
|
-
"libc": [
|
|
170
|
-
"glibc"
|
|
171
|
-
],
|
|
172
163
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
173
164
|
"optional": true,
|
|
174
165
|
"os": [
|
|
@@ -182,9 +173,6 @@
|
|
|
182
173
|
"cpu": [
|
|
183
174
|
"x64"
|
|
184
175
|
],
|
|
185
|
-
"libc": [
|
|
186
|
-
"musl"
|
|
187
|
-
],
|
|
188
176
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
189
177
|
"optional": true,
|
|
190
178
|
"os": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleapai/kai-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Kai Code Bridge runs Kai Code on your computer or server with your own coding subscriptions and local previews.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
"postinstall": "node scripts/postinstall.mjs",
|
|
26
26
|
"runtime:check": "node scripts/runtime-smoke.mjs",
|
|
27
27
|
"prepack": "node scripts/prepare-release.mjs",
|
|
28
|
-
"postpack": "node scripts/prepare-release.mjs --clean"
|
|
28
|
+
"postpack": "node scripts/prepare-release.mjs --clean",
|
|
29
|
+
"cli": "node scripts/dev-cli.mjs",
|
|
30
|
+
"test:cli": "node --test test/cli.test.mjs"
|
|
29
31
|
},
|
|
30
32
|
"dependencies": {
|
|
31
33
|
"@agentclientprotocol/claude-agent-acp": "0.81.1",
|
package/src/api.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// POST /gleapcode/bridge/pair/poll → { status, device?, token? } (pollToken)
|
|
5
5
|
// PUT /gleapcode/bridge/devices/me/hello { name, platform, version, profiles, repos, roots }
|
|
6
6
|
// POST /gleapcode/bridge/devices/me/heartbeat { running: [turnIds] }
|
|
7
|
+
// POST /gleapcode/bridge/turns/:id/ack { via } → 410 when the turn already ended
|
|
7
8
|
// POST /gleapcode/bridge/turns/:id/events { events: [contract lines] }
|
|
8
9
|
// POST /gleapcode/bridge/turns/:id/result { result, changes, status }
|
|
9
10
|
// POST /gleapcode/bridge/devices/me/public-hosts { sessionId, services } → { domain, hosts, tunnel, displaced }
|
|
@@ -96,6 +97,15 @@ export class BridgeApi {
|
|
|
96
97
|
heartbeat(payload) {
|
|
97
98
|
return this.request("POST", "/gleapcode/bridge/devices/me/heartbeat", payload);
|
|
98
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* "This machine has the turn" — sent for every delivery of a
|
|
102
|
+
* `bridge.turn.start` (push, the Server's re-send, the pending poll); the
|
|
103
|
+
* Server stops re-sending once one lands. Short timeout: the turn does not
|
|
104
|
+
* wait on a slow Server. 410 = the turn ended before it got here.
|
|
105
|
+
*/
|
|
106
|
+
turnAck(turnId, payload) {
|
|
107
|
+
return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/ack`, payload, { timeoutMs: 5_000 });
|
|
108
|
+
}
|
|
99
109
|
turnEvents(turnId, events) {
|
|
100
110
|
return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/events`, { events });
|
|
101
111
|
}
|
package/src/config.mjs
CHANGED
|
@@ -31,6 +31,7 @@ export function defaultConfig() {
|
|
|
31
31
|
appBase: DEFAULT_APP_BASE,
|
|
32
32
|
realtime: DEFAULT_REALTIME,
|
|
33
33
|
device: null, // { id, name, token, organisationId, userId }
|
|
34
|
+
machineId: null, // stable per-install id; re-pairing keeps the same device row
|
|
34
35
|
roots: [], // extra scan roots
|
|
35
36
|
profiles: [
|
|
36
37
|
{ id: "claude-ambient", harness: "claude", kind: "ambient", label: "Claude (this machine)" },
|
package/src/daemon.mjs
CHANGED
|
@@ -5,7 +5,9 @@ import { cloneRepository, locateRepository } from './repository-setup.mjs';
|
|
|
5
5
|
//
|
|
6
6
|
// Commands arrive on `private-bridge-<deviceId>` (Sockudo / Pusher
|
|
7
7
|
// protocol, the same channel family the dashboard uses):
|
|
8
|
-
// bridge.turn.start {
|
|
8
|
+
// bridge.turn.start { turnId, sessionId, profileId, repos:[{key, mode, base, carryUncommitted}], resend?, ...AgentRunOpts }
|
|
9
|
+
// — acknowledged with POST /turns/:id/ack; the Server re-sends an
|
|
10
|
+
// unacknowledged start (`resend: n`), so a turn id seen before is ignored
|
|
9
11
|
// bridge.turn.cancel { turnId }
|
|
10
12
|
// bridge.turn.steer { turnId, steer: { id, text } } — inject into the running turn;
|
|
11
13
|
// answered with a {type:"steer", id, outcome} turn event
|
|
@@ -57,6 +59,8 @@ const REALTIME_RETRY_MS = 15_000;
|
|
|
57
59
|
const HEARTBEAT_MS = 30_000;
|
|
58
60
|
/** Safety net under the realtime channel: ask the server for work it thinks we run (see pullPendingWork). */
|
|
59
61
|
const PENDING_POLL_MS = 60_000;
|
|
62
|
+
/** Turn ids remembered after they ran, so a late duplicate delivery never runs twice (see startTurn). */
|
|
63
|
+
const HANDLED_TURNS_MAX = 200;
|
|
60
64
|
const USAGE_REFRESH_MS = 10 * 60_000;
|
|
61
65
|
// Harness model catalogues change on releases, not by the minute.
|
|
62
66
|
const MODELS_REFRESH_MS = 6 * 60 * 60_000;
|
|
@@ -563,6 +567,42 @@ export class BridgeDaemon {
|
|
|
563
567
|
this.writeInflight(this.readInflight().filter((e) => e.turnId !== turnId));
|
|
564
568
|
}
|
|
565
569
|
|
|
570
|
+
get handledTurnsPath() {
|
|
571
|
+
return join(this.kaiHome, "state", "handled-turns.json");
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Turn ids this machine already ran (newest last, capped). Kept on disk:
|
|
576
|
+
* a turn whose result was lost is still "running" on the Server, and the
|
|
577
|
+
* pending poll after a restart must not run it a second time.
|
|
578
|
+
*/
|
|
579
|
+
handledTurns() {
|
|
580
|
+
if (!this.handled) {
|
|
581
|
+
let ids = [];
|
|
582
|
+
try {
|
|
583
|
+
const raw = JSON.parse(readFileSync(this.handledTurnsPath, "utf8"));
|
|
584
|
+
if (Array.isArray(raw)) ids = raw.filter((id) => typeof id === "string");
|
|
585
|
+
} catch {
|
|
586
|
+
/* none yet */
|
|
587
|
+
}
|
|
588
|
+
this.handled = new Set(ids.slice(-HANDLED_TURNS_MAX));
|
|
589
|
+
}
|
|
590
|
+
return this.handled;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
rememberHandledTurn(turnId) {
|
|
594
|
+
const handled = this.handledTurns();
|
|
595
|
+
handled.delete(turnId);
|
|
596
|
+
handled.add(turnId);
|
|
597
|
+
while (handled.size > HANDLED_TURNS_MAX) handled.delete(handled.values().next().value);
|
|
598
|
+
try {
|
|
599
|
+
mkdirSync(join(this.kaiHome, "state"), { recursive: true });
|
|
600
|
+
writeFileSync(this.handledTurnsPath, JSON.stringify([...handled]));
|
|
601
|
+
} catch {
|
|
602
|
+
/* best effort */
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
566
606
|
/** Turns this machine was running when it was killed — report them dead. */
|
|
567
607
|
async reportInterruptedTurns() {
|
|
568
608
|
const entries = this.readInflight();
|
|
@@ -571,6 +611,8 @@ export class BridgeDaemon {
|
|
|
571
611
|
for (const entry of entries) {
|
|
572
612
|
const { turnId } = entry;
|
|
573
613
|
this.log("warn", "turn.interrupted", { turnId, agent: entry.agent });
|
|
614
|
+
// Reported failed below; a re-send or a poll must not start it over.
|
|
615
|
+
this.rememberHandledTurn(turnId);
|
|
574
616
|
await this.api
|
|
575
617
|
.turnResult(turnId, {
|
|
576
618
|
status: "failed",
|
|
@@ -867,8 +909,11 @@ export class BridgeDaemon {
|
|
|
867
909
|
}
|
|
868
910
|
for (const turn of pending?.turns || []) {
|
|
869
911
|
if (this.running.has(turn.turnId) || cancelled.has(String(turn.turnId))) continue;
|
|
912
|
+
// Ran here already and its result never landed: the Server's reaper
|
|
913
|
+
// settles it — running the work again is never the fix.
|
|
914
|
+
if (this.handledTurns().has(turn.turnId)) continue;
|
|
870
915
|
this.log("info", "turn.recovered", { turnId: turn.turnId, via });
|
|
871
|
-
void this.startTurn(turn).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
|
|
916
|
+
void this.startTurn(turn, { via }).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
|
|
872
917
|
}
|
|
873
918
|
} catch (err) {
|
|
874
919
|
// Older server without the endpoint, or the server is restarting: the
|
|
@@ -931,7 +976,7 @@ export class BridgeDaemon {
|
|
|
931
976
|
.catch((err) => this.log("warn", "update.turn.refused.failed", { turnId: data.turnId, error: err?.message }));
|
|
932
977
|
return;
|
|
933
978
|
}
|
|
934
|
-
return this.startTurn(data);
|
|
979
|
+
return this.startTurn(data, { via: data?.resend ? "resend" : "push" });
|
|
935
980
|
case "bridge.turn.cancel":
|
|
936
981
|
this.running.get(data.turnId)?.ctrl.abort();
|
|
937
982
|
return;
|
|
@@ -1665,9 +1710,10 @@ export class BridgeDaemon {
|
|
|
1665
1710
|
}
|
|
1666
1711
|
|
|
1667
1712
|
/** Map the Server's repo bindings onto local checkouts; throw a readable error when one is missing. */
|
|
1668
|
-
async bindRepos(turn) {
|
|
1713
|
+
async bindRepos(turn, { onPrepare, signal } = {}) {
|
|
1669
1714
|
const bound = [];
|
|
1670
1715
|
for (const r of turn.repos || []) {
|
|
1716
|
+
if (signal?.aborted) break; // stopped mid-prep: the caller reports the cancel
|
|
1671
1717
|
const group = this.repoGroups.find((g) => g.key === r.key);
|
|
1672
1718
|
if (!group) throw new Error(`Repository ${r.key} is not checked out on this device.`);
|
|
1673
1719
|
const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
|
|
@@ -1678,6 +1724,7 @@ export class BridgeDaemon {
|
|
|
1678
1724
|
sessionId: turn.sessionId,
|
|
1679
1725
|
title: turn.title,
|
|
1680
1726
|
gitEnv,
|
|
1727
|
+
onPrepare,
|
|
1681
1728
|
}));
|
|
1682
1729
|
bound.push({ key: r.key, ...ws });
|
|
1683
1730
|
if (ws.deps) this.log("info", "deps.seed", { repo: r.key, ...ws.deps });
|
|
@@ -1736,11 +1783,25 @@ export class BridgeDaemon {
|
|
|
1736
1783
|
return adopted;
|
|
1737
1784
|
}
|
|
1738
1785
|
|
|
1739
|
-
|
|
1786
|
+
/**
|
|
1787
|
+
* `via` = how the turn reached us: push, resend (the Server re-sent an
|
|
1788
|
+
* unacknowledged start), poll or reconnect (pullPendingWork).
|
|
1789
|
+
*/
|
|
1790
|
+
async startTurn(turn, { via = "push" } = {}) {
|
|
1740
1791
|
const { turnId } = turn;
|
|
1741
|
-
|
|
1792
|
+
// One turn, several deliveries by design: the Server re-sends a start
|
|
1793
|
+
// until it is acknowledged, and the poll / reconnect pull replays what
|
|
1794
|
+
// the Server still expects. Only the first delivery runs. Every one is
|
|
1795
|
+
// acknowledged — a duplicate usually means the first ack was missed.
|
|
1796
|
+
if (this.running.has(turnId) || this.handledTurns().has(turnId)) {
|
|
1797
|
+
this.log("info", "turn.duplicate", { turnId, via, running: this.running.has(turnId) });
|
|
1798
|
+
await this.ackTurn(turnId, via);
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1742
1801
|
const ctrl = new AbortController();
|
|
1743
1802
|
const entry = { ctrl, control: null, sessionId: turn.sessionId };
|
|
1803
|
+
// Claimed before the first await, so a second delivery arriving while
|
|
1804
|
+
// the ack is in flight is the duplicate above.
|
|
1744
1805
|
this.running.set(turnId, entry);
|
|
1745
1806
|
const releaseAwake = keepAwake();
|
|
1746
1807
|
let outcome = null;
|
|
@@ -1749,8 +1810,39 @@ export class BridgeDaemon {
|
|
|
1749
1810
|
this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null, harness: turn.harness ?? null, profileId: turn.profileId ?? null, startedAt: new Date().toISOString(), ...session });
|
|
1750
1811
|
const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
|
|
1751
1812
|
try {
|
|
1813
|
+
// Receipt first: the Server stops re-sending the start.
|
|
1814
|
+
if ((await this.ackTurn(turnId, via)) === "ended") {
|
|
1815
|
+
// Stopped or reaped before it reached this machine — nobody is
|
|
1816
|
+
// waiting for it, and its result would be refused anyway.
|
|
1817
|
+
this.log("info", "turn.ended_before_start", { turnId, via });
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1752
1820
|
const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === turn.profileId) ?? { id: "gleap-key", kind: "gleap-key", harness: turn.harness };
|
|
1753
|
-
|
|
1821
|
+
// Prep no longer blocks the daemon, so a Stop (or session close) can
|
|
1822
|
+
// land before the runner exists — and runTurn's abort listener would
|
|
1823
|
+
// never hear a signal that fired before it was added. Never start the
|
|
1824
|
+
// agent for a turn that was stopped on the way.
|
|
1825
|
+
const stoppedBeforeRun = () => {
|
|
1826
|
+
if (!ctrl.signal.aborted) return false;
|
|
1827
|
+
this.log("info", "turn.cancelled_before_run", { turnId });
|
|
1828
|
+
outcome = { status: "cancelled", changes: [], profileId: profile.id };
|
|
1829
|
+
return true;
|
|
1830
|
+
};
|
|
1831
|
+
if (stoppedBeforeRun()) return;
|
|
1832
|
+
// A fresh worktree costs a fetch, a checkout and a node_modules clone
|
|
1833
|
+
// — up to minutes for a big repo — while the dashboard already calls
|
|
1834
|
+
// the session running. Say what is happening before it starts (once
|
|
1835
|
+
// per turn; resumed worktrees and local checkouts skip it).
|
|
1836
|
+
let preparing = null;
|
|
1837
|
+
const onPrepare = () => {
|
|
1838
|
+
preparing ??= (async () => {
|
|
1839
|
+
batcher.push({ type: "tool_status", message: `Preparing workspace on ${this.config.device?.name || "this machine"}…` });
|
|
1840
|
+
await batcher.flush();
|
|
1841
|
+
})();
|
|
1842
|
+
return preparing;
|
|
1843
|
+
};
|
|
1844
|
+
const bound = await this.bindRepos(turn, { onPrepare, signal: ctrl.signal });
|
|
1845
|
+
if (stoppedBeforeRun()) return;
|
|
1754
1846
|
// Multi-repo: the runner's cwd is the first repo; the others are
|
|
1755
1847
|
// reachable as siblings under the same worktree root or by their
|
|
1756
1848
|
// local paths — the prompt lists them.
|
|
@@ -1764,6 +1856,7 @@ export class BridgeDaemon {
|
|
|
1764
1856
|
const live = await this.describeLivePreview(turn, bound, batcher);
|
|
1765
1857
|
const previewNote = live.note;
|
|
1766
1858
|
const mcpServers = live.hasLivePreview ? [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)] : turn.mcpServers;
|
|
1859
|
+
if (stoppedBeforeRun()) return;
|
|
1767
1860
|
const res = await runTurn({
|
|
1768
1861
|
turn: { ...turn, task: `${turn.task}${repoNote}${previewNote}`, mcpServers },
|
|
1769
1862
|
profile,
|
|
@@ -1861,12 +1954,30 @@ export class BridgeDaemon {
|
|
|
1861
1954
|
.catch((err) => this.log("error", "result.lost", { turnId, error: err.message }));
|
|
1862
1955
|
}
|
|
1863
1956
|
this.forgetInflight(turnId);
|
|
1957
|
+
this.rememberHandledTurn(turnId);
|
|
1864
1958
|
releaseAwake();
|
|
1865
1959
|
this.running.delete(turnId);
|
|
1866
1960
|
if ((this.updatePending || this.restartPending) && this.running.size === 0) void this.checkForUpdate();
|
|
1867
1961
|
}
|
|
1868
1962
|
}
|
|
1869
1963
|
|
|
1964
|
+
/**
|
|
1965
|
+
* Tell the Server this machine has the turn. Resolves "acked", "ended"
|
|
1966
|
+
* (410: the turn is over — it must not run), or "unknown" (an older Server
|
|
1967
|
+
* without the route, a network hiccup): the turn then runs as it always
|
|
1968
|
+
* did. Never throws.
|
|
1969
|
+
*/
|
|
1970
|
+
async ackTurn(turnId, via) {
|
|
1971
|
+
try {
|
|
1972
|
+
await this.api.turnAck(turnId, { via });
|
|
1973
|
+
return "acked";
|
|
1974
|
+
} catch (err) {
|
|
1975
|
+
if (err?.status === 410) return "ended";
|
|
1976
|
+
this.log(err?.status === 404 ? "debug" : "warn", "turn.ack.failed", { turnId, via, error: err?.message });
|
|
1977
|
+
return "unknown";
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1870
1981
|
// ── preview browser ────────────────────────────────────────────────
|
|
1871
1982
|
/** Overridable seam (tests, embedded hosts): a browser the warm-up and the Playwright MCP can launch. */
|
|
1872
1983
|
ensurePreviewBrowser() {
|