@gleapai/kai-bridge 0.2.5 → 0.2.6
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/bin/kai-bridge.mjs +24 -1
- package/package.json +1 -1
- package/src/daemon.mjs +106 -2
- package/src/selfupdate.mjs +105 -0
package/bin/kai-bridge.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { HARNESSES, createManagedProfile, describeProfiles, findBinary, loginCom
|
|
|
27
27
|
import { defaultRoots, groupByRepo, scanRoots, toDeviceRepoReport } from "../src/repos.mjs";
|
|
28
28
|
import { install, isInstalled, uninstall, isEphemeralBinPath } from "../src/service.mjs";
|
|
29
29
|
import { HARNESS_INFO, describeHarnesses, installHarness } from "../src/harnesses.mjs";
|
|
30
|
+
import { fetchLatestVersion, installVersion, installedVersion, isNewer } from "../src/selfupdate.mjs";
|
|
30
31
|
|
|
31
32
|
const BIN = fileURLToPath(import.meta.url);
|
|
32
33
|
const argv = process.argv.slice(2);
|
|
@@ -244,6 +245,28 @@ try {
|
|
|
244
245
|
case "doctor":
|
|
245
246
|
await doctor();
|
|
246
247
|
break;
|
|
248
|
+
case "update": {
|
|
249
|
+
// Manual update: ignores autoUpdate and a previous failed attempt.
|
|
250
|
+
const current = installedVersion();
|
|
251
|
+
const latest = await fetchLatestVersion();
|
|
252
|
+
if (!isNewer(latest, current)) {
|
|
253
|
+
out(`kai-bridge ${current} is up to date.`);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
out(`Updating kai-bridge ${current} → ${latest}…`);
|
|
257
|
+
const res = await installVersion(latest);
|
|
258
|
+
if (!res.ok) fail(`Update failed: ${res.error}\n\nTry: npm i -g @gleapai/kai-bridge@${latest}`);
|
|
259
|
+
const cfg = loadConfig();
|
|
260
|
+
cfg.selfUpdate = { lastAttempt: { version: latest, ok: true, at: new Date().toISOString(), error: null } };
|
|
261
|
+
saveConfig(cfg);
|
|
262
|
+
if (isInstalled()) {
|
|
263
|
+
install({ binPath: BIN, logDir: join(KAI_HOME, "logs") });
|
|
264
|
+
out(`Installed ${latest} and restarted the background service.`);
|
|
265
|
+
} else {
|
|
266
|
+
out(`Installed ${latest}. Restart \`kai-bridge start\` to run it.`);
|
|
267
|
+
}
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
247
270
|
default: {
|
|
248
271
|
// A bare `kai-bridge` in a terminal IS the setup — first run pairs,
|
|
249
272
|
// later runs open with keep / reconnect / log out, so it doubles as
|
|
@@ -256,7 +279,7 @@ try {
|
|
|
256
279
|
out(`kai-bridge — run Gleap Kai Code on this machine
|
|
257
280
|
|
|
258
281
|
setup guided onboarding (pair · service · sign-ins)
|
|
259
|
-
login | logout | install | uninstall | start | status | doctor
|
|
282
|
+
login | logout | install | uninstall | start | status | doctor | update
|
|
260
283
|
harness list|install <id>|login <id>
|
|
261
284
|
profile list|add <id> --harness claude|codex|cursor|login <id>|remove <id>
|
|
262
285
|
repo scan|roots [add|remove <dir>]|primary <repoKey> <path>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleapai/kai-bridge",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Run Gleap Kai Code sessions on your own machine with your own Claude Code / Codex login — and preview your real dev servers from the dashboard or the phone.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/daemon.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, mate
|
|
|
24
24
|
import { ServiceRunner, detectDevConfig, previewMcpServer, readDevConfig } from "./preview.mjs";
|
|
25
25
|
import { describeHarnesses, installHarness, probeHarnessAuth } from "./harnesses.mjs";
|
|
26
26
|
import { probeHarnessModels } from "./models.mjs";
|
|
27
|
+
import { decideUpdate, fetchLatestVersion, installVersion, installedVersion, isNewer } from "./selfupdate.mjs";
|
|
27
28
|
import { dirname } from "node:path";
|
|
28
29
|
import { fileURLToPath } from "node:url";
|
|
29
30
|
|
|
@@ -38,7 +39,14 @@ const USAGE_REFRESH_MS = 10 * 60_000;
|
|
|
38
39
|
const MODELS_REFRESH_MS = 6 * 60 * 60_000;
|
|
39
40
|
/** Harnesses that can tell us which models they offer (Cursor has no such surface). */
|
|
40
41
|
const MODEL_PROBE_HARNESSES = ["claude", "codex"];
|
|
41
|
-
|
|
42
|
+
// Read from package.json: the hard-coded "0.1.0" this used to be is what
|
|
43
|
+
// every device reported as its bridge version, so nobody could tell who
|
|
44
|
+
// was behind.
|
|
45
|
+
const VERSION = installedVersion();
|
|
46
|
+
const SELF_UPDATE_CHECK_MS = 6 * 60 * 60_000;
|
|
47
|
+
// EX_TEMPFAIL: a failure exit on purpose — launchd (SuccessfulExit:false)
|
|
48
|
+
// and systemd (Restart=on-failure) restart the service on the new code.
|
|
49
|
+
const RESTART_EXIT_CODE = 75;
|
|
42
50
|
|
|
43
51
|
export function createLogger(kaiHome = KAI_HOME) {
|
|
44
52
|
mkdirSync(join(kaiHome, "logs"), { recursive: true });
|
|
@@ -109,6 +117,9 @@ export class BridgeDaemon {
|
|
|
109
117
|
this.usageByProfile = new Map(); // profileId → plan-usage snapshot (claude only)
|
|
110
118
|
this.modelsByHarness = new Map(); // harnessId → models the signed-in login offers (see refreshHarnessModels)
|
|
111
119
|
this.stopped = false;
|
|
120
|
+
this.updateAvailable = null; // newer registry version, when one exists
|
|
121
|
+
this.updateError = null; // why the last self-update did not apply
|
|
122
|
+
this.updating = false; // npm is replacing our files — refuse new turns
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
async scanRepos() {
|
|
@@ -131,6 +142,9 @@ export class BridgeDaemon {
|
|
|
131
142
|
repos,
|
|
132
143
|
roots: [...defaultRoots(), ...(this.config.roots || [])],
|
|
133
144
|
repoModes: this.config.repoModes || {},
|
|
145
|
+
updateAvailable: this.updateAvailable || null,
|
|
146
|
+
updateError: this.updateError || null,
|
|
147
|
+
autoUpdate: this.config.autoUpdate !== false,
|
|
134
148
|
});
|
|
135
149
|
}
|
|
136
150
|
|
|
@@ -169,6 +183,10 @@ export class BridgeDaemon {
|
|
|
169
183
|
await new Promise((r) => setTimeout(r, delay));
|
|
170
184
|
}
|
|
171
185
|
}
|
|
186
|
+
// Update BEFORE picking up work: a machine that wakes up applies the
|
|
187
|
+
// new version first, so its next turn runs on current code. Applying
|
|
188
|
+
// restarts the process (this call never returns in that case).
|
|
189
|
+
await this.checkForUpdate();
|
|
172
190
|
this.connectRealtime();
|
|
173
191
|
// REF'd on purpose: this timer is what keeps the process alive. With
|
|
174
192
|
// it unref'd, a realtime socket that gave up for good left no handle
|
|
@@ -190,7 +208,82 @@ export class BridgeDaemon {
|
|
|
190
208
|
void this.refreshHarnessModels();
|
|
191
209
|
this.modelsTimer = setInterval(() => void this.refreshHarnessModels(), MODELS_REFRESH_MS);
|
|
192
210
|
this.modelsTimer.unref?.();
|
|
193
|
-
this.
|
|
211
|
+
this.updateTimer = setInterval(() => void this.checkForUpdate(), SELF_UPDATE_CHECK_MS);
|
|
212
|
+
this.updateTimer.unref?.();
|
|
213
|
+
this.log("info", "started", { device: this.config.device.id, version: VERSION });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── self-update ────────────────────────────────────────────────────
|
|
217
|
+
/**
|
|
218
|
+
* Compare with the registry and, when idle, install the newer version
|
|
219
|
+
* and restart. A busy daemon retries once the turn ends; a version that
|
|
220
|
+
* failed to install is not retried (the dashboard shows the hint and
|
|
221
|
+
* `kai-bridge update` forces it). Never throws.
|
|
222
|
+
*/
|
|
223
|
+
async checkForUpdate() {
|
|
224
|
+
if (this.stopped || this.updating) return;
|
|
225
|
+
// Tests and embedded hosts (the Desktop app ships its own copy).
|
|
226
|
+
if (process.env.KAI_BRIDGE_NO_SELF_UPDATE === "1") return;
|
|
227
|
+
let latest;
|
|
228
|
+
try {
|
|
229
|
+
latest = await fetchLatestVersion();
|
|
230
|
+
} catch (err) {
|
|
231
|
+
this.log("warn", "update.check.failed", { error: err?.message });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
this.updateAvailable = isNewer(latest, VERSION) ? latest : null;
|
|
235
|
+
const decision = decideUpdate({
|
|
236
|
+
current: VERSION,
|
|
237
|
+
latest,
|
|
238
|
+
running: this.running.size,
|
|
239
|
+
autoUpdate: this.config.autoUpdate !== false,
|
|
240
|
+
lastAttempt: this.config.selfUpdate?.lastAttempt ?? null,
|
|
241
|
+
});
|
|
242
|
+
if (decision.action === "skip") {
|
|
243
|
+
if (decision.reason === "busy") this.updatePending = latest;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (decision.action === "notify") {
|
|
247
|
+
this.updateError = decision.reason === "disabled" ? null : this.config.selfUpdate?.lastAttempt?.error || "previous install failed";
|
|
248
|
+
this.log("info", "update.available", { latest, reason: decision.reason });
|
|
249
|
+
await this.hello().catch(() => undefined);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
await this.applyUpdate(latest);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async applyUpdate(version) {
|
|
256
|
+
this.updating = true;
|
|
257
|
+
this.updatePending = null;
|
|
258
|
+
this.log("info", "update.installing", { from: VERSION, to: version });
|
|
259
|
+
const res = await installVersion(version);
|
|
260
|
+
this.config.selfUpdate = { lastAttempt: { version, ok: res.ok, at: new Date().toISOString(), error: res.error || null } };
|
|
261
|
+
try {
|
|
262
|
+
saveConfig(this.config, this.kaiHome);
|
|
263
|
+
} catch {
|
|
264
|
+
/* best-effort */
|
|
265
|
+
}
|
|
266
|
+
if (!res.ok) {
|
|
267
|
+
this.updating = false;
|
|
268
|
+
this.updateError = res.error;
|
|
269
|
+
this.log("error", "update.failed", { version, error: res.error });
|
|
270
|
+
await this.hello().catch(() => undefined);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
this.log("info", "update.installed", { version, restarting: true });
|
|
274
|
+
this.restartForUpdate();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Hand the machine to the new version: the service restarts us. */
|
|
278
|
+
restartForUpdate() {
|
|
279
|
+
this.stop();
|
|
280
|
+
if (platform() === "win32") {
|
|
281
|
+
// Task Scheduler only starts at logon — respawn ourselves instead.
|
|
282
|
+
const child = spawn(process.execPath, [process.argv[1], "start"], { detached: true, stdio: "ignore", windowsHide: true });
|
|
283
|
+
child.unref();
|
|
284
|
+
process.exit(0);
|
|
285
|
+
}
|
|
286
|
+
process.exit(RESTART_EXIT_CODE);
|
|
194
287
|
}
|
|
195
288
|
|
|
196
289
|
/** Install/version state per harness, plus the models it reported (when probed). */
|
|
@@ -277,6 +370,7 @@ export class BridgeDaemon {
|
|
|
277
370
|
clearInterval(this.heartbeat);
|
|
278
371
|
clearInterval(this.usageTimer);
|
|
279
372
|
clearInterval(this.modelsTimer);
|
|
373
|
+
clearInterval(this.updateTimer);
|
|
280
374
|
if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
|
|
281
375
|
for (const ctrl of this.running.values()) ctrl.abort();
|
|
282
376
|
for (const runner of this.services.values()) runner.stopAll();
|
|
@@ -481,6 +575,15 @@ export class BridgeDaemon {
|
|
|
481
575
|
this.log("info", "command", { name, turnId: data?.turnId, commandId: data?.commandId });
|
|
482
576
|
switch (name) {
|
|
483
577
|
case "bridge.turn.start":
|
|
578
|
+
if (this.updating) {
|
|
579
|
+
// Our files are being replaced under us — a turn started now
|
|
580
|
+
// would run half old, half new code. Seconds later the restarted
|
|
581
|
+
// daemon picks queued turns up again.
|
|
582
|
+
await this.api
|
|
583
|
+
.turnResult(data.turnId, { status: "failed", error: "This machine is updating kai-bridge — send the turn again in a minute." })
|
|
584
|
+
.catch((err) => this.log("warn", "update.turn.refused.failed", { turnId: data.turnId, error: err?.message }));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
484
587
|
return this.startTurn(data);
|
|
485
588
|
case "bridge.turn.cancel":
|
|
486
589
|
this.running.get(data.turnId)?.abort();
|
|
@@ -868,6 +971,7 @@ export class BridgeDaemon {
|
|
|
868
971
|
this.forgetInflight(turnId);
|
|
869
972
|
releaseAwake();
|
|
870
973
|
this.running.delete(turnId);
|
|
974
|
+
if (this.updatePending && this.running.size === 0) void this.checkForUpdate();
|
|
871
975
|
}
|
|
872
976
|
}
|
|
873
977
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Self-update: the daemon checks npm for a newer @gleapai/kai-bridge and,
|
|
2
|
+
// when idle, installs it into the same global prefix the login service
|
|
3
|
+
// already points at, then exits so launchd/systemd restart it on the new
|
|
4
|
+
// code (Windows: it re-spawns itself). Paired machines used to stay on
|
|
5
|
+
// whatever version each teammate last typed `npm i -g` for.
|
|
6
|
+
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { platform } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
export const PACKAGE_NAME = "@gleapai/kai-bridge";
|
|
14
|
+
export const REGISTRY_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
15
|
+
export const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
|
+
|
|
17
|
+
/** The version of THIS install (re-read after an install to verify it). */
|
|
18
|
+
export function installedVersion(root = PACKAGE_ROOT) {
|
|
19
|
+
return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseVersion(v) {
|
|
23
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(v || "").trim());
|
|
24
|
+
if (!m) return null;
|
|
25
|
+
return { parts: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** semver-ish: -1 / 0 / 1; a prerelease ranks below its release; unparsable = equal. */
|
|
29
|
+
export function compareVersions(a, b) {
|
|
30
|
+
const pa = parseVersion(a);
|
|
31
|
+
const pb = parseVersion(b);
|
|
32
|
+
if (!pa || !pb) return 0;
|
|
33
|
+
for (let i = 0; i < 3; i += 1) {
|
|
34
|
+
if (pa.parts[i] !== pb.parts[i]) return pa.parts[i] > pb.parts[i] ? 1 : -1;
|
|
35
|
+
}
|
|
36
|
+
if (!!pa.pre !== !!pb.pre) return pa.pre ? -1 : 1;
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isNewer(latest, current) {
|
|
41
|
+
return compareVersions(latest, current) > 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The registry's `latest` dist-tag. Plain HTTPS — no npm needed to look. */
|
|
45
|
+
export async function fetchLatestVersion({ fetchImpl = globalThis.fetch, timeoutMs = 8_000, url = REGISTRY_LATEST_URL } = {}) {
|
|
46
|
+
const ctrl = new AbortController();
|
|
47
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetchImpl(url, { signal: ctrl.signal, headers: { accept: "application/json" } });
|
|
50
|
+
if (!res.ok) throw new Error(`registry ${res.status}`);
|
|
51
|
+
const body = await res.json();
|
|
52
|
+
if (!body?.version) throw new Error("registry response has no version");
|
|
53
|
+
return String(body.version);
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What to do about `latest`, given the daemon's state:
|
|
61
|
+
* skip — nothing to do now (up to date, or a turn is running: retry later)
|
|
62
|
+
* notify — a newer version exists but we must not install it (auto-update
|
|
63
|
+
* off, or this exact version already failed to install once)
|
|
64
|
+
* update — install it now
|
|
65
|
+
*/
|
|
66
|
+
export function decideUpdate({ current, latest, running = 0, autoUpdate = true, lastAttempt = null }) {
|
|
67
|
+
if (!latest || !isNewer(latest, current)) return { action: "skip", reason: "up_to_date" };
|
|
68
|
+
if (autoUpdate === false) return { action: "notify", reason: "disabled" };
|
|
69
|
+
if (lastAttempt && lastAttempt.version === latest && lastAttempt.ok === false) return { action: "notify", reason: "failed_before" };
|
|
70
|
+
if (running > 0) return { action: "skip", reason: "busy" };
|
|
71
|
+
return { action: "update", reason: "newer" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The npm that belongs to the node running us — same global prefix as the install. */
|
|
75
|
+
export function npmBinary(execPath = process.execPath, os = platform()) {
|
|
76
|
+
const candidate = join(dirname(execPath), os === "win32" ? "npm.cmd" : "npm");
|
|
77
|
+
return existsSync(candidate) ? candidate : os === "win32" ? "npm.cmd" : "npm";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `npm install -g <pkg>@<version>` into the current prefix, then re-read the
|
|
82
|
+
* package.json at this install's path to prove the files were replaced.
|
|
83
|
+
* Async on purpose: the daemon's heartbeat keeps running while npm works.
|
|
84
|
+
*/
|
|
85
|
+
export function installVersion(version, { npm = npmBinary(), timeoutMs = 5 * 60_000, root = PACKAGE_ROOT, exec = execFile } = {}) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
exec(
|
|
88
|
+
npm,
|
|
89
|
+
["install", "-g", `${PACKAGE_NAME}@${version}`, "--no-fund", "--no-audit", "--loglevel=error"],
|
|
90
|
+
{ timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024, shell: platform() === "win32" },
|
|
91
|
+
(err, stdout, stderr) => {
|
|
92
|
+
const output = `${stdout || ""}${stderr || ""}`.trim().slice(-2000);
|
|
93
|
+
if (err) return resolve({ ok: false, version, error: `${err.message}${output ? `\n${output}` : ""}`.slice(0, 1500) });
|
|
94
|
+
let installed = null;
|
|
95
|
+
try {
|
|
96
|
+
installed = installedVersion(root);
|
|
97
|
+
} catch (readErr) {
|
|
98
|
+
return resolve({ ok: false, version, error: `installed, but could not read ${root}/package.json: ${readErr.message}` });
|
|
99
|
+
}
|
|
100
|
+
if (installed !== version) return resolve({ ok: false, version, error: `npm finished but ${root} still reports ${installed}` });
|
|
101
|
+
resolve({ ok: true, version });
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
}
|