@gleapai/kai-bridge 0.2.4 → 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.
@@ -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.4",
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
@@ -20,10 +20,11 @@ import { KAI_HOME, defaultConfig, loadConfig, saveConfig } from "./config.mjs";
20
20
  import { runTurn } from "./executor.mjs";
21
21
  import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
22
22
  import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
23
- import { collectChanges, commitAndPush, copyPrimaryEnvFiles, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
23
+ import { collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
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
- const VERSION = "0.1.0";
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.log("info", "started", { device: this.config.device.id });
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();
@@ -738,6 +841,53 @@ export class BridgeDaemon {
738
841
  return bound;
739
842
  }
740
843
 
844
+ /**
845
+ * The agent's repo brief: which checkouts are part of the session (only
846
+ * those get committed, pushed and turned into PRs), and the recipe for
847
+ * bringing another repo on this device into the session. Ticket #146130:
848
+ * the session was scoped to Desktop, the fix belonged in JavaScript-SDK,
849
+ * and the agent's work there was stranded because nothing told it how a
850
+ * repo joins a session.
851
+ */
852
+ repoBrief(turn, bound) {
853
+ const slug = sessionSlug(turn.sessionId, turn.title);
854
+ const lines = ["", "", "Repositories for this task (changes here are committed, pushed and opened as pull requests when the turn ends — you cannot commit or push yourself):"];
855
+ for (const b of bound) lines.push(`- ${b.key}: ${b.cwd}`);
856
+ const others = this.repoGroups.filter((g) => !bound.some((b) => b.key === g.key));
857
+ if (others.length) {
858
+ lines.push(
859
+ "",
860
+ "If the fix belongs in a repository that is NOT listed above, do not edit its primary checkout. Create the session worktree for it with the command below and work there — it is picked up like the repositories above (branch kai/" + slug + ", pushed at turn end, PR opened by Gleap):",
861
+ );
862
+ for (const g of others.slice(0, 40)) {
863
+ const base = g.primary.defaultBranch || "main";
864
+ 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}`);
865
+ }
866
+ if (others.length > 40) lines.push(`- (${others.length - 40} more repositories on this device — same recipe, path ~/.kai/worktrees/<repo>/${slug})`);
867
+ }
868
+ return lines.join("\n");
869
+ }
870
+
871
+ /**
872
+ * Session worktrees the agent created for repos outside the turn's
873
+ * bindings (via the recipe in `repoBrief`). They are bound like the
874
+ * declared repos so their changes are committed, pushed and reported.
875
+ */
876
+ adoptSessionWorktrees(turn, bound) {
877
+ const slug = sessionSlug(turn.sessionId, turn.title);
878
+ const adopted = [];
879
+ for (const g of this.repoGroups) {
880
+ if (bound.some((b) => b.key === g.key)) continue;
881
+ const cwd = worktreePath(this.kaiHome, g.name, slug);
882
+ if (!existsSync(cwd)) continue;
883
+ const branch = currentBranch(cwd);
884
+ if (!branch || branch === "HEAD") continue;
885
+ adopted.push({ key: g.key, cwd, mode: "worktree", branch, base: g.primary.defaultBranch || "main", adopted: true });
886
+ this.log("info", "worktree.adopted", { turnId: turn.turnId, repo: g.key, cwd, branch });
887
+ }
888
+ return adopted;
889
+ }
890
+
741
891
  async startTurn(turn) {
742
892
  const { turnId } = turn;
743
893
  if (this.running.has(turnId)) return;
@@ -755,7 +905,7 @@ export class BridgeDaemon {
755
905
  // local paths — the prompt lists them.
756
906
  const workDir = bound[0]?.cwd;
757
907
  if (!workDir) throw new Error("Turn has no repositories.");
758
- const repoNote = bound.length > 1 ? `\n\nRepositories for this task:\n${bound.map((b) => `- ${b.key}: ${b.cwd}`).join("\n")}` : "";
908
+ const repoNote = this.repoBrief(turn, bound);
759
909
  // Previews are manual-only (dashboard "Start preview") — a turn
760
910
  // never boots dev servers on its own. When the user already has a
761
911
  // preview running for this session, describe it to the agent and
@@ -773,7 +923,7 @@ export class BridgeDaemon {
773
923
  });
774
924
  await batcher.flush();
775
925
  const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
776
- const changes = bound.map((b) => {
926
+ const changes = [...bound, ...this.adoptSessionWorktrees(turn, bound)].map((b) => {
777
927
  ensureCommitExcludes(b.cwd, { allowDevConfig: !!turn.allowDevConfig });
778
928
  const diff = collectChanges(b.cwd);
779
929
  // Build turns in worktree mode publish the session branch so the
@@ -786,7 +936,9 @@ export class BridgeDaemon {
786
936
  message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}`,
787
937
  })
788
938
  : null;
789
- return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, ...diff, push };
939
+ // `cwd` travels with the change so the dashboard can point at
940
+ // work that stayed on this machine (files but no push).
941
+ return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, adopted: b.adopted || undefined, ...diff, push };
790
942
  });
791
943
  // Built OUTSIDE the report call: if posting the result throws, the
792
944
  // catch below must not turn a finished turn into a failed one. The
@@ -819,6 +971,7 @@ export class BridgeDaemon {
819
971
  this.forgetInflight(turnId);
820
972
  releaseAwake();
821
973
  this.running.delete(turnId);
974
+ if (this.updatePending && this.running.size === 0) void this.checkForUpdate();
822
975
  }
823
976
  }
824
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
+ }
package/src/workspace.mjs CHANGED
@@ -107,6 +107,15 @@ export function materializeBinding({ kaiHome, repo, binding, sessionId, title, b
107
107
  return { cwd: dir, mode, branch, base, resumed: false };
108
108
  }
109
109
 
110
+ /** The branch checked out at `cwd` (null when it is not a git checkout). */
111
+ export function currentBranch(cwd) {
112
+ try {
113
+ return git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) || null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
110
119
  /** Diff of what the turn changed (for the dashboard's file-changes panel). */
111
120
  export function collectChanges(cwd) {
112
121
  const status = git(cwd, ["status", "--porcelain"]);