@gleapai/kai-bridge 0.7.0 → 0.9.1

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/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";
@@ -57,6 +54,7 @@ import {
57
54
  rewriteStorageState,
58
55
  storageStateIsEmpty,
59
56
  } from "./preview-login.mjs";
57
+ import { GIT_AUTH_TTL_MS, gitAuthEnv, isGitAuthError } from "./git-auth.mjs";
60
58
  import { describeHarnesses, installHarness, probeHarnessAuth } from "./harnesses.mjs";
61
59
  import { probeHarnessModels } from "./models.mjs";
62
60
  import { decideRestart, decideUpdate, fetchLatestVersion, installVersion, installedVersion, installedVersionOrNull, isNewer } from "./selfupdate.mjs";
@@ -161,6 +159,8 @@ export class BridgeDaemon {
161
159
  this.kaiHome = kaiHome;
162
160
  this.log = log;
163
161
  this.api = new BridgeApi({ apiBase: config.apiBase, token: config.device?.token });
162
+ /** repoKey → { authHeader, at }: Server-issued git credentials (see git-auth.mjs). */
163
+ this.gitAuth = new Map();
164
164
  this.realtimeFactory = realtimeFactory;
165
165
  this.running = new Map(); // turnId → { ctrl: AbortController, control?: (obj) => boolean }
166
166
  this.services = new Map(); // sessionId → ServiceRunner (lives across turns)
@@ -188,14 +188,6 @@ export class BridgeDaemon {
188
188
 
189
189
  async hello() {
190
190
  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
191
  const repos = toDeviceRepoReport(this.repoGroups);
200
192
  return this.api.hello({
201
193
  name: this.config.device?.name,
@@ -272,16 +264,9 @@ export class BridgeDaemon {
272
264
  // machine stayed offline until the next login — silently.
273
265
  this.heartbeat = setInterval(() => {
274
266
  this.api
275
- .heartbeat({ running: [...this.running.keys()], ...(this.config.hosted ? { resources: hostedResources(this) } : {}) })
267
+ .heartbeat({ running: [...this.running.keys()] })
276
268
  .catch((err) => this.onApiError("heartbeat", err));
277
269
  }, 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
270
  // Plan-usage windows for the composer popover. Fire-and-forget on a
286
271
  // slow cadence, unref'd (the heartbeat keeps the process alive), and
287
272
  // NEVER in hello's path — the probe spawns the CLI (~2s per profile).
@@ -409,8 +394,6 @@ export class BridgeDaemon {
409
394
  let changed = false;
410
395
  const profiles = resolveProfiles(this.config, this.kaiHome);
411
396
  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
397
  // The user's own ~/.claude / ~/.codex first, then any signed-in
415
398
  // managed profile — the catalogue is per account, not per profile.
416
399
  const candidates = profiles
@@ -480,8 +463,6 @@ export class BridgeDaemon {
480
463
  clearInterval(this.usageTimer);
481
464
  clearInterval(this.modelsTimer);
482
465
  clearInterval(this.updateTimer);
483
- clearInterval(this.hostedRepoTimer);
484
- this.hostedTunnel?.close();
485
466
  if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
486
467
  for (const entry of this.running.values()) entry.ctrl.abort();
487
468
  const reports = [];
@@ -569,9 +550,11 @@ export class BridgeDaemon {
569
550
  }
570
551
  }
571
552
 
553
+ /** Merges: the runner pid and the verify artifacts dir arrive after the turn's session meta. */
572
554
  rememberInflight(turnId, meta = {}) {
573
- const rest = this.readInflight().filter((e) => e.turnId !== turnId);
574
- this.writeInflight([...rest, { turnId, ...meta }]);
555
+ const all = this.readInflight();
556
+ const existing = all.find((e) => e.turnId === turnId);
557
+ this.writeInflight([...all.filter((e) => e.turnId !== turnId), { ...existing, turnId, ...meta }]);
575
558
  }
576
559
 
577
560
  forgetInflight(turnId) {
@@ -633,11 +616,34 @@ export class BridgeDaemon {
633
616
  }
634
617
  }
635
618
 
636
- /** ServiceRunner hook: every dev-server pid is persisted while it lives. */
637
- trackServicePid(sessionId, name, pid, op) {
619
+ /**
620
+ * ServiceRunner hook: every dev-server pid is persisted while it lives —
621
+ * with its port and the session's title/link, so `kai-bridge ps` can
622
+ * show a preview-only session without a turn to borrow the meta from.
623
+ */
624
+ trackServicePid(sessionId, name, pid, op, { port = null } = {}) {
638
625
  if (!Number.isInteger(pid)) return;
639
626
  const rest = this.readPreviewPids().filter((e) => e.pid !== pid);
640
- this.writePreviewPids(op === "add" ? [...rest, { pid, name, sessionId, startedAt: new Date().toISOString() }] : rest);
627
+ const session = this.sessionMeta?.get(sessionId) ?? {};
628
+ this.writePreviewPids(op === "add" ? [...rest, { pid, name, sessionId, port, ...session, startedAt: new Date().toISOString() }] : rest);
629
+ }
630
+
631
+ /**
632
+ * Title, dashboard link and repo keys of a session this machine works
633
+ * on — copied into the inflight / preview-pid entries (ps.mjs reads
634
+ * them back). Remembered from turn starts and preview starts alike.
635
+ */
636
+ rememberSession(sessionId, { title = null, sessionUrl = null, repos = [] } = {}) {
637
+ if (typeof sessionId !== "string") return {};
638
+ this.sessionMeta ??= new Map();
639
+ const prev = this.sessionMeta.get(sessionId) ?? {};
640
+ const meta = {
641
+ title: typeof title === "string" && title ? title : prev.title ?? null,
642
+ sessionUrl: typeof sessionUrl === "string" && sessionUrl ? sessionUrl : prev.sessionUrl ?? null,
643
+ repos: Array.isArray(repos) && repos.length ? repos.map((r) => (typeof r === "string" ? r : r?.key)).filter((k) => typeof k === "string") : prev.repos ?? [],
644
+ };
645
+ this.sessionMeta.set(sessionId, meta);
646
+ return meta;
641
647
  }
642
648
 
643
649
  /**
@@ -805,8 +811,6 @@ export class BridgeDaemon {
805
811
  }
806
812
  return this.startTurn(data);
807
813
  case "bridge.turn.cancel":
808
- this.hostedCancelled ??= new Set();
809
- this.hostedCancelled.add(data.turnId);
810
814
  this.running.get(data.turnId)?.ctrl.abort();
811
815
  return;
812
816
  case "bridge.turn.steer":
@@ -880,8 +884,8 @@ export class BridgeDaemon {
880
884
  * truncation. `companionRemotes` (Server-resolved `{ key: remote }`) is
881
885
  * how non-github companions get cloned; `note` survives onto error writes.
882
886
  */
883
- async previewStart({ sessionId, title, repos, companionRemotes = null }) {
884
- if (this.config.hosted) { this.hostedPreviewWork ??= new Set(); this.hostedPreviewWork.add(sessionId); }
887
+ async previewStart({ sessionId, title, repos, sessionUrl = null, companionRemotes = null }) {
888
+ this.rememberSession(sessionId, { title, sessionUrl, repos });
885
889
  let lastNote = null;
886
890
  const skipped = [];
887
891
  // Every report is also RETURNED: the verify turn boots the preview
@@ -889,11 +893,7 @@ export class BridgeDaemon {
889
893
  const report = async (payload) => {
890
894
  if (payload.note) lastNote = payload.note;
891
895
  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 }));
896
+ await this.api.sessionPreview(sessionId, body).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
897
897
  return body;
898
898
  };
899
899
  const fail = (err, ctx = {}) => report(toPreviewErrorPayload(err, { ...ctx, skipped }));
@@ -908,7 +908,7 @@ export class BridgeDaemon {
908
908
  return fail(new PreviewError(`Repository ${r.key} is not checked out on this device.`, { code: "companion_missing", repo: r.key }), { repo: r.key });
909
909
  }
910
910
  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);
911
+ const cwd = mode === "local" ? group.primary.path : this.findSessionWorktree(group.name, sessionId, title);
912
912
  if (!cwd || !existsSync(cwd)) {
913
913
  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
914
  }
@@ -1073,7 +1073,6 @@ export class BridgeDaemon {
1073
1073
  findSessionWorktree(repoName, sessionId, title) {
1074
1074
  const exact = worktreePath(this.kaiHome, repoName, sessionSlug(sessionId, title));
1075
1075
  if (existsSync(exact)) return exact;
1076
- if (this.config.hosted) return exact;
1077
1076
  const suffix = `-${String(sessionId || "").slice(-8)}`;
1078
1077
  if (suffix.length < 2) return exact;
1079
1078
  try {
@@ -1162,7 +1161,7 @@ export class BridgeDaemon {
1162
1161
  this.previewIdleTimers.delete(sessionId);
1163
1162
  const runner = this.services.get(sessionId);
1164
1163
  if (!runner) return;
1165
- if (!this.config.hosted && await this.previewHasConnections(runner)) {
1164
+ if (await this.previewHasConnections(runner)) {
1166
1165
  this.log("info", "preview.idle.connected", { sessionId });
1167
1166
  if (this.services.get(sessionId) === runner) this.armPreviewIdleTimer(sessionId);
1168
1167
  return;
@@ -1195,7 +1194,6 @@ export class BridgeDaemon {
1195
1194
  }
1196
1195
 
1197
1196
  async previewStop({ sessionId }) {
1198
- this.hostedTunnel?.unregisterSession(sessionId);
1199
1197
  this.manualPreviews?.delete(sessionId);
1200
1198
  this.verifyOwnedPreviews?.delete(sessionId);
1201
1199
  // A sign-in window for this session has nothing left to sign in to.
@@ -1213,7 +1211,6 @@ export class BridgeDaemon {
1213
1211
  * dashboard why — with the classified log line.
1214
1212
  */
1215
1213
  onServiceDied(sessionId, info) {
1216
- this.hostedTunnel?.unregisterSession(sessionId);
1217
1214
  const runner = this.services.get(sessionId);
1218
1215
  if (!runner) return Promise.resolve();
1219
1216
  this.log("warn", "preview.service.died", { sessionId, name: info.name, code: info.code, errorCode: info.errorCode });
@@ -1243,7 +1240,6 @@ export class BridgeDaemon {
1243
1240
  runner = new ServiceRunner({
1244
1241
  kaiHome: this.kaiHome,
1245
1242
  sessionId,
1246
- ...(this.hostedTunnel ? { registerPublicService: (name, port, protocol) => this.hostedTunnel.register(sessionId, name, port, protocol) } : {}),
1247
1243
  log: this.log,
1248
1244
  onStatus: (message) => {
1249
1245
  this.log("info", "preview.status", { sessionId, message });
@@ -1253,7 +1249,7 @@ export class BridgeDaemon {
1253
1249
  ...(this.describeListener ? { describeListener: this.describeListener } : {}),
1254
1250
  ...(this.previewSettleMs != null ? { settleMs: this.previewSettleMs } : {}),
1255
1251
  onServiceExit: (info) => this.onServiceDied(sessionId, info),
1256
- onProcess: (name, pid, op) => this.trackServicePid(sessionId, name, pid, op),
1252
+ onProcess: (name, pid, op, info) => this.trackServicePid(sessionId, name, pid, op, info),
1257
1253
  });
1258
1254
  this.services.set(sessionId, runner);
1259
1255
  }
@@ -1261,21 +1257,23 @@ export class BridgeDaemon {
1261
1257
  }
1262
1258
 
1263
1259
  /** Map the Server's repo bindings onto local checkouts; throw a readable error when one is missing. */
1264
- bindRepos(turn) {
1260
+ async bindRepos(turn) {
1265
1261
  const bound = [];
1266
1262
  for (const r of turn.repos || []) {
1267
1263
  const group = this.repoGroups.find((g) => g.key === r.key);
1268
1264
  if (!group) throw new Error(`Repository ${r.key} is not checked out on this device.`);
1269
1265
  const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
1270
- const ws = materializeBinding({
1266
+ const ws = await this.withGitAuth(r.key, (gitEnv) => materializeBinding({
1271
1267
  kaiHome: this.kaiHome,
1272
- repo: { name: this.config.hosted ? group.key : group.name, primaryPath: group.primary.path, defaultBranch: group.primary.defaultBranch },
1268
+ repo: { name: group.name, primaryPath: group.primary.path, defaultBranch: group.primary.defaultBranch },
1273
1269
  binding: { mode, base: r.base, carryUncommitted: r.carryUncommitted },
1274
1270
  sessionId: turn.sessionId,
1275
1271
  title: turn.title,
1276
- });
1272
+ gitEnv,
1273
+ }));
1277
1274
  bound.push({ key: r.key, ...ws });
1278
1275
  if (ws.deps) this.log("info", "deps.seed", { repo: r.key, ...ws.deps });
1276
+ if (ws.fetch && (ws.fetch.stale || ws.fetch.attempts > 1)) this.log("warn", "workspace.fetch.contended", { repo: r.key, ...ws.fetch });
1279
1277
  // Remember the choice per repo (the UI asks once, then sticks).
1280
1278
  this.config.repoModes = { ...(this.config.repoModes || {}), [r.key]: mode };
1281
1279
  }
@@ -1303,7 +1301,7 @@ export class BridgeDaemon {
1303
1301
  );
1304
1302
  for (const g of others.slice(0, 40)) {
1305
1303
  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}`);
1304
+ 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
1305
  }
1308
1306
  if (others.length > 40) lines.push(`- (${others.length - 40} more repositories on this device — same recipe, path ~/.kai/worktrees/<repo>/${slug})`);
1309
1307
  }
@@ -1320,7 +1318,7 @@ export class BridgeDaemon {
1320
1318
  const adopted = [];
1321
1319
  for (const g of this.repoGroups) {
1322
1320
  if (bound.some((b) => b.key === g.key)) continue;
1323
- const cwd = worktreePath(this.kaiHome, this.config.hosted ? g.key : g.name, slug);
1321
+ const cwd = worktreePath(this.kaiHome, g.name, slug);
1324
1322
  if (!existsSync(cwd)) continue;
1325
1323
  const branch = currentBranch(cwd);
1326
1324
  if (!branch || branch === "HEAD") continue;
@@ -1333,8 +1331,6 @@ export class BridgeDaemon {
1333
1331
  async startTurn(turn) {
1334
1332
  const { turnId } = turn;
1335
1333
  if (this.running.has(turnId)) return;
1336
- if (this.config.hosted && !(await awaitHostedAdmission(this, turn))) return;
1337
- if (this.running.has(turnId)) return;
1338
1334
  const ctrl = new AbortController();
1339
1335
  const entry = { ctrl, control: null, sessionId: turn.sessionId };
1340
1336
  this.running.set(turnId, entry);
@@ -1345,13 +1341,13 @@ export class BridgeDaemon {
1345
1341
  // turn — see prepareVerifyTurn / finishVerification.
1346
1342
  const isVerify = turn.agent === "kai-verifier";
1347
1343
  let verify = null;
1348
- this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null });
1344
+ // Everything `kai-bridge ps` shows for this turn; the runner pid follows at spawn.
1345
+ const session = this.rememberSession(turn.sessionId, { title: turn.title, sessionUrl: turn.sessionUrl, repos: turn.repos });
1346
+ this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null, harness: turn.harness ?? null, profileId: turn.profileId ?? null, startedAt: new Date().toISOString(), ...session });
1349
1347
  const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
1350
1348
  try {
1351
1349
  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
- const bound = this.bindRepos(turn);
1350
+ const bound = await this.bindRepos(turn);
1355
1351
  // Multi-repo: the runner's cwd is the first repo; the others are
1356
1352
  // reachable as siblings under the same worktree root or by their
1357
1353
  // local paths — the prompt lists them.
@@ -1379,7 +1375,7 @@ export class BridgeDaemon {
1379
1375
  }
1380
1376
  previewNote = verify.note;
1381
1377
  // The evidence dir is where a restart mid-run finds partial footage.
1382
- this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent, artifactsDir: verify.artifactsDir });
1378
+ this.rememberInflight(turnId, { artifactsDir: verify.artifactsDir });
1383
1379
  mcpServers = [
1384
1380
  ...(turn.mcpServers || []),
1385
1381
  previewMcpServer(RUNNER_DIR, {
@@ -1414,6 +1410,7 @@ export class BridgeDaemon {
1414
1410
  signal: ctrl.signal,
1415
1411
  onSpawn: (handle) => {
1416
1412
  entry.control = handle.control;
1413
+ if (Number.isInteger(handle.pid)) this.rememberInflight(turnId, { pid: handle.pid });
1417
1414
  },
1418
1415
  onEvent: (ev) => {
1419
1416
  if (verify) {
@@ -1434,16 +1431,11 @@ export class BridgeDaemon {
1434
1431
  onLog: (l) => this.log("debug", "runner", { line: l.slice(0, 500) }),
1435
1432
  });
1436
1433
  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
1434
  const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
1443
1435
  // Plan and verify turns are read-only: worktrees are restored, local
1444
1436
  // checkouts only reported, nothing is ever committed or pushed.
1445
1437
  const readOnlyTurn = !!turn.planMode || isVerify;
1446
- const changes = [...bound, ...this.adoptSessionWorktrees(turn, bound)].map((b) => {
1438
+ const changes = await Promise.all([...bound, ...this.adoptSessionWorktrees(turn, bound)].map(async (b) => {
1447
1439
  ensureCommitExcludes(b.cwd, { allowDevConfig: !!turn.allowDevConfig });
1448
1440
  // A read-only turn must leave the worktree as it found it — see
1449
1441
  // discardChanges. Local checkouts are the user's; only report.
@@ -1465,12 +1457,20 @@ export class BridgeDaemon {
1465
1457
  // Build turns in worktree mode publish the session branch so the
1466
1458
  // Server can open the PR; read-only turns and local mode never push.
1467
1459
  const shouldPush = completed && b.mode === "worktree" && !readOnlyTurn && diff.files.length > 0;
1460
+ const pushOnce = (gitEnv) => {
1461
+ const out = commitAndPush(b.cwd, {
1462
+ branch: b.branch,
1463
+ allowDevConfig: !!turn.allowDevConfig,
1464
+ message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}`,
1465
+ gitEnv,
1466
+ });
1467
+ // commitAndPush reports instead of throwing; surface an auth
1468
+ // rejection so withGitAuth can retry the push (the commit exists).
1469
+ if (!out.pushed && !gitEnv && isGitAuthError(out.error)) throw Object.assign(new Error(out.error), { push: out });
1470
+ return out;
1471
+ };
1468
1472
  const push = shouldPush
1469
- ? commitAndPush(b.cwd, {
1470
- branch: b.branch,
1471
- allowDevConfig: !!turn.allowDevConfig,
1472
- message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}`,
1473
- })
1473
+ ? await this.withGitAuth(b.key, pushOnce).catch((err) => err.push ?? { committed: false, pushed: false, branch: b.branch, error: err.message })
1474
1474
  : isVerify
1475
1475
  ? false
1476
1476
  : null;
@@ -1480,7 +1480,7 @@ export class BridgeDaemon {
1480
1480
  // Server can write a real pull request description.
1481
1481
  const described = push?.pushed ? describeBranchChanges(b.cwd, b.base) : null;
1482
1482
  return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, adopted: b.adopted || undefined, ...diff, push, ...(described ? described : {}) };
1483
- });
1483
+ }));
1484
1484
  // The read-only notices above were queued AFTER the post-turn
1485
1485
  // flush; land them before the result closes the turn (the Server
1486
1486
  // answers 410 for events on an ended turn).
@@ -1504,9 +1504,11 @@ export class BridgeDaemon {
1504
1504
  profileId: profile.id,
1505
1505
  };
1506
1506
  } catch (err) {
1507
- this.log("error", "turn.failed", { turnId, error: err.message });
1507
+ this.log("error", "turn.failed", { turnId, error: err.message, ...(err.code ? { code: err.code, repo: err.repo } : {}) });
1508
1508
  await batcher.flush().catch(() => {});
1509
- outcome = outcome ?? { status: "failed", error: err.message };
1509
+ // A WorkspaceError names why the checkout could not be prepared;
1510
+ // the Server turns `failure.code` into a one-click retry.
1511
+ outcome = outcome ?? { status: "failed", error: err.message, ...(err.code ? { failure: { code: err.code, repo: err.repo ?? null } } : {}) };
1510
1512
  } finally {
1511
1513
  // One report, retried until it lands — a dropped result is what
1512
1514
  // leaves a session spinning forever in the dashboard.
@@ -1712,7 +1714,7 @@ export class BridgeDaemon {
1712
1714
  this.previewStatusListeners.set(sessionId, heartbeat.note);
1713
1715
  let preview;
1714
1716
  try {
1715
- preview = await this.previewStart({ sessionId, title: turn.title, repos: turn.repos, companionRemotes: turn.companionRemotes ?? null });
1717
+ preview = await this.previewStart({ sessionId, title: turn.title, repos: turn.repos, sessionUrl: turn.sessionUrl ?? null, companionRemotes: turn.companionRemotes ?? null });
1716
1718
  } finally {
1717
1719
  this.previewStatusListeners?.delete(sessionId);
1718
1720
  heartbeat.stop();
@@ -2432,6 +2434,43 @@ export class BridgeDaemon {
2432
2434
  return { note: notes.join(""), hasLivePreview: previews.length > 0 };
2433
2435
  }
2434
2436
 
2437
+ /**
2438
+ * Run a git network op with this machine's own credentials; when those are
2439
+ * rejected, retry ONCE with the Server-issued credentials for `repoKey`
2440
+ * (connected repos only — anything else rethrows the original error).
2441
+ */
2442
+ async withGitAuth(repoKey, run) {
2443
+ try {
2444
+ return await run(null);
2445
+ } catch (err) {
2446
+ if (!repoKey || !isGitAuthError(err)) throw err;
2447
+ let authHeader;
2448
+ try {
2449
+ authHeader = await this.gitAuthHeader(repoKey);
2450
+ } catch (credErr) {
2451
+ this.log("warn", "git.auth.unavailable", { repo: repoKey, error: credErr.message });
2452
+ throw err;
2453
+ }
2454
+ this.log("info", "git.auth.fallback", { repo: repoKey });
2455
+ try {
2456
+ return await run(gitAuthEnv(authHeader));
2457
+ } catch (retryErr) {
2458
+ // Stale or revoked: never reuse it for the next attempt.
2459
+ if (isGitAuthError(retryErr)) this.gitAuth.delete(repoKey);
2460
+ throw retryErr;
2461
+ }
2462
+ }
2463
+ }
2464
+
2465
+ async gitAuthHeader(repoKey) {
2466
+ const cached = this.gitAuth.get(repoKey);
2467
+ if (cached && Date.now() - cached.at < GIT_AUTH_TTL_MS) return cached.authHeader;
2468
+ const { authHeader } = await this.api.gitCredentials(repoKey);
2469
+ if (!authHeader) throw new Error("The Server returned no git credentials.");
2470
+ this.gitAuth.set(repoKey, { authHeader, at: Date.now() });
2471
+ return authHeader;
2472
+ }
2473
+
2435
2474
  /**
2436
2475
  * Clone `remote` into the preferred root as `name`. Hardened for
2437
2476
  * unattended runs: no credential prompts (`GIT_TERMINAL_PROMPT=0`,
@@ -2443,7 +2482,7 @@ export class BridgeDaemon {
2443
2482
  async cloneRepo({ commandId, remote, name, repoKey, timeoutMs = CLONE_TIMEOUT_MS }) {
2444
2483
  const root = preferredCloneRoot(this.repoGroups, (this.config.roots || [])[0] ?? defaultRoots()[0]);
2445
2484
  try {
2446
- const target = await cloneRepository({ remote, name, root, repoKey, timeoutMs });
2485
+ const target = await this.withGitAuth(repoKey, (gitEnv) => cloneRepository({ remote, name, root, repoKey, timeoutMs, gitEnv }));
2447
2486
  await this.scanRepos();
2448
2487
  // Ack only after the Server has the new inventory: recovery checks
2449
2488
  // readiness before automatically retrying the blocked session.
@@ -2501,10 +2540,10 @@ export class BridgeDaemon {
2501
2540
  }
2502
2541
  }
2503
2542
 
2504
- async harnessInstall({ commandId, harness }) {
2543
+ async harnessInstall({ commandId, harness, update = false }) {
2505
2544
  const lines = [];
2506
2545
  try {
2507
- const res = await installHarness(harness, { kaiHome: this.kaiHome, onLog: (l) => { lines.push(l); this.log("info", "harness.install", { harness, line: l }); } });
2546
+ const res = await installHarness(harness, { kaiHome: this.kaiHome, update, onLog: (l) => { lines.push(l); this.log("info", "harness.install", { harness, line: l }); } });
2508
2547
  await this.hello();
2509
2548
  if (commandId) await this.api.commandAck(commandId, { ok: res.ok, version: res.version, log: lines });
2510
2549
  } catch (err) {
@@ -2514,24 +2553,34 @@ export class BridgeDaemon {
2514
2553
  }
2515
2554
 
2516
2555
  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?.();
2556
+ try {
2557
+ const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === profileId);
2558
+ if (!profile) throw new Error(`Unknown profile ${profileId}`);
2559
+ if (profile.kind === "managed") createManagedProfile(profile.harness, profile.id, this.kaiHome);
2560
+ // Logins are interactive: open a terminal on this machine running the
2561
+ // harness's own login under the profile's config dir, and re-announce
2562
+ // the profile state once it exits.
2563
+ await installHarness(profile.harness, { kaiHome: this.kaiHome });
2564
+ const child = openLoginTerminal(profile.harness, profile.configDir, this.kaiHome);
2565
+ if (child) await new Promise((resolve, reject) => {
2566
+ child.once('spawn', resolve);
2567
+ child.once('error', reject);
2568
+ });
2569
+ child?.unref?.();
2570
+ const command = `kai-bridge profile login '${String(profile.id).replace(/'/g, "'\\''")}'${profile.harness === 'codex' ? ' --device-auth' : ''}`;
2571
+ if (commandId) await this.api.commandAck(commandId, { ok: true, opened: child ? "terminal" : "ssh", ...(!child ? { command } : {}) });
2572
+ const poll = setInterval(() => {
2573
+ this.hello().catch(() => {});
2574
+ // First sign-in for this harness: learn its models as soon as the
2575
+ // login lands (later refreshes ride the slow timer).
2576
+ if (!this.modelsByHarness.has(profile.harness)) void this.refreshHarnessModels();
2577
+ }, 15_000);
2578
+ poll.unref?.();
2579
+ setTimeout(() => clearInterval(poll), 10 * 60_000).unref?.();
2580
+ } catch (err) {
2581
+ if (commandId) await this.api.commandAck(commandId, { ok: false, error: err.message }).catch(() => {});
2582
+ else throw err;
2583
+ }
2535
2584
  }
2536
2585
  }
2537
2586
 
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,10 +148,11 @@ 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?.({
154
+ /** The runner's pid — the daemon records it for `kai-bridge ps`. */
155
+ pid: child.pid,
166
156
  /** Write one control line; false when the runner is already gone. */
167
157
  control(obj) {
168
158
  if (child.exitCode !== null || child.killed || !child.stdin.writable) return false;
@@ -199,8 +189,7 @@ export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, onS
199
189
  child.stderr.on("data", (d) => onLog(String(d)));
200
190
  const onAbort = () => {
201
191
  try {
202
- if (process.env.KAI_HOSTED === '1') process.kill(-child.pid, 'SIGTERM');
203
- else child.kill("SIGTERM");
192
+ child.kill("SIGTERM");
204
193
  } catch {
205
194
  /* gone */
206
195
  }
@@ -0,0 +1,38 @@
1
+ // Git credentials of last resort.
2
+ //
3
+ // Every git network op runs with the machine's own credentials first: that
4
+ // keeps the user's identity, their SSH keys and their org SSO exactly as
5
+ // they are. A device that was never signed in to GitHub (a fresh Mac, a
6
+ // shared box, a CI-ish machine) has none, and a clone, the prep fetch or
7
+ // the end-of-turn push then dies with "Authentication failed".
8
+ //
9
+ // For those — and ONLY those — the Server hands out the same short-lived
10
+ // provider credentials Kai Code Cloud clones with, for one repository, on
11
+ // demand. The header is passed through the environment for that single git
12
+ // command: it never reaches `.git/config`, the remote URL, or `ps` output.
13
+
14
+ const AUTH_FAILURE = /authentication failed|invalid username or (?:token|password)|could not read (?:username|password)|terminal prompts disabled|permission denied \(publickey\)|remote: (?:invalid|write access|permission)|403 forbidden|repository not found|please ask the owner|support for password authentication was removed/i;
15
+
16
+ export const isGitAuthError = (err) =>
17
+ // `cause`: workspace.mjs wraps a failed fetch in a WorkspaceError.
18
+ AUTH_FAILURE.test([err?.stderr, err?.message, err?.cause?.stderr, err?.cause?.message, typeof err === "string" ? err : ""]
19
+ .map((part) => String(part ?? "")).join(" "));
20
+
21
+ /**
22
+ * `git -c http.extraheader=…` without the command line: GIT_CONFIG_COUNT
23
+ * config pairs are read by git itself, so the token stays out of argv.
24
+ */
25
+ export function gitAuthEnv(authHeader) {
26
+ if (!authHeader) return null;
27
+ return {
28
+ GIT_CONFIG_COUNT: "1",
29
+ GIT_CONFIG_KEY_0: "http.extraheader",
30
+ GIT_CONFIG_VALUE_0: String(authHeader),
31
+ // The header authenticates; never let a helper prompt on top of it.
32
+ GIT_TERMINAL_PROMPT: "0",
33
+ GIT_ASKPASS: "echo",
34
+ };
35
+ }
36
+
37
+ /** Keep a repo's credentials only while they are certainly still valid. */
38
+ export const GIT_AUTH_TTL_MS = 30 * 60_000;
@@ -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
+ }