@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 CHANGED
@@ -1,8 +1,7 @@
1
- # kai-bridge
1
+ # Kai Code Bridge
2
2
 
3
- Run [Gleap Kai Code](https://gleap.io) sessions on your own machine — with your own
4
- Claude Code / Codex login — and preview your real dev servers from the dashboard or the
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.
@@ -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
- const c = loginCommand(p.harness, p.configDir);
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 <claude|codex|cursor>|login <id>");
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 …]`);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.7.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.7.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.7.0",
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.",
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/codex-broker.mjs');
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()], ...(this.config.hosted ? { resources: hostedResources(this) } : {}) })
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
- if (['running', 'error'].includes(body.status)) this.hostedPreviewWork?.delete(sessionId);
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(this.config.hosted ? group.key : group.name, sessionId, title);
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 (!this.config.hosted && await this.previewHasConnections(runner)) {
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: this.config.hosted ? group.key : group.name, primaryPath: group.primary.path, defaultBranch: group.primary.defaultBranch },
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, this.config.hosted ? g.key : g.name, slug)} origin/${base}`);
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, this.config.hosted ? g.key : g.name, slug);
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
- const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === profileId);
2518
- if (!profile) throw new Error(`Unknown profile ${profileId}`);
2519
- if (profile.kind === "managed") createManagedProfile(profile.harness, profile.id, this.kaiHome);
2520
- // Logins are interactive: open a terminal on this machine running the
2521
- // harness's own login under the profile's config dir, and re-announce
2522
- // the profile state once it exits.
2523
- const child = openLoginTerminal(profile.harness, profile.configDir);
2524
- if (!child) throw new Error(`Could not open a terminal for ${profile.harness} login on this device.`);
2525
- child.unref?.();
2526
- if (commandId) await this.api.commandAck(commandId, { ok: true, opened: "terminal" });
2527
- const poll = setInterval(() => {
2528
- this.hello().catch(() => {});
2529
- // First sign-in for this harness: learn its models as soon as the
2530
- // login lands (later refreshes ride the slow timer).
2531
- if (!this.modelsByHarness.has(profile.harness)) void this.refreshHarnessModels();
2532
- }, 15_000);
2533
- poll.unref?.();
2534
- setTimeout(() => clearInterval(poll), 10 * 60_000).unref?.();
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 { boundHostedProcess } from './hosted-resources.mjs';
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, detached: process.env.KAI_HOSTED === '1', stdio: ["pipe", "pipe", "pipe"] });
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
- if (process.env.KAI_HOSTED === '1') process.kill(-child.pid, 'SIGTERM');
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
+ }