@gleapai/kai-bridge 0.9.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/README.md CHANGED
@@ -8,6 +8,7 @@ npm i -g @gleapai/kai-bridge # one install, so the background service has a st
8
8
  kai-bridge login # pair this machine with your Gleap account
9
9
  kai-bridge install # run at login (launchd / systemd / Task Scheduler)
10
10
  kai-bridge status
11
+ kai-bridge ps # sessions running on this machine, with dashboard links (--json for tooling)
11
12
  ```
12
13
 
13
14
  (`npx @gleapai/kai-bridge login` works for a quick look, but `install` needs the
@@ -7,6 +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 ps [--json] what runs right now: daemon, turns, dev servers — with dashboard links
10
11
  // kai-bridge harness list|install <id>|update <id>|login <id> [--device-auth] Claude Code + Codex are bundled; Cursor is downloaded
11
12
  // kai-bridge profile list|add <id> --harness claude|codex|cursor [--label …]|login <id>|remove <id>
12
13
  // kai-bridge repo scan|roots [add <dir>|remove <dir>]|primary <repoKey> <path>
@@ -28,6 +29,7 @@ import { defaultRoots, groupByRepo, scanRoots, toDeviceRepoReport } from "../src
28
29
  import { install, isInstalled, uninstall, isEphemeralBinPath } from "../src/service.mjs";
29
30
  import { HARNESS_INFO, describeHarnesses, installHarness } from "../src/harnesses.mjs";
30
31
  import { fetchLatestVersion, installVersion, installedVersion, isNewer } from "../src/selfupdate.mjs";
32
+ import { collectProcessList, formatProcessList } from "../src/ps.mjs";
31
33
 
32
34
  const BIN = fileURLToPath(import.meta.url);
33
35
  const argv = process.argv.slice(2);
@@ -68,6 +70,12 @@ async function status() {
68
70
  for (const r of toDeviceRepoReport(groups)) out(` ${r.key.padEnd(48)} ${r.primaryPath}${r.checkouts > 1 ? ` (+${r.checkouts - 1})` : ""} ${r.dirtyCount ? `· ${r.dirtyCount} dirty` : ""}`);
69
71
  }
70
72
 
73
+ /** Sessions running on this machine — read from the daemon's state files, so it works while the daemon is busy or down. */
74
+ function ps() {
75
+ const list = collectProcessList();
76
+ out(flags.json ? JSON.stringify(list, null, 2) : formatProcessList(list));
77
+ }
78
+
71
79
  async function profile() {
72
80
  const config = loadConfig();
73
81
  const sub = positional[0];
@@ -231,6 +239,9 @@ try {
231
239
  case "status":
232
240
  await status();
233
241
  break;
242
+ case "ps":
243
+ ps();
244
+ break;
234
245
  case "profile":
235
246
  await profile();
236
247
  break;
@@ -280,7 +291,7 @@ try {
280
291
  out(`kai-bridge — run Gleap Kai Code on this machine
281
292
 
282
293
  setup guided onboarding (pair · service · sign-ins)
283
- login | logout | install | uninstall | start | status | doctor | update
294
+ login | logout | install | uninstall | start | status | ps | doctor | update
284
295
  harness list|install <id>|update <id>|login <id> [--device-auth]
285
296
  profile list|add <id> --harness claude|codex|cursor|login <id>|remove <id>
286
297
  repo scan|roots [add|remove <dir>]|primary <repoKey> <path>
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@gleapai/kai-bridge",
9
- "version": "0.9.0",
9
+ "version": "0.9.1",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
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",
package/src/api.mjs CHANGED
@@ -210,6 +210,14 @@ export class BridgeApi {
210
210
  pendingTurns() {
211
211
  return this.request("GET", "/gleapcode/bridge/devices/me/pending");
212
212
  }
213
+ /**
214
+ * Provider git credentials for one connected repository — asked for only
215
+ * after this machine's own credentials were rejected (see git-auth.mjs).
216
+ */
217
+ gitCredentials(repoKey) {
218
+ return this.request("POST", "/gleapcode/bridge/devices/me/git-credentials", { repoKey });
219
+ }
220
+
213
221
  commandAck(commandId, payload) {
214
222
  return this.request("POST", `/gleapcode/bridge/commands/${encodeURIComponent(commandId)}/ack`, payload);
215
223
  }
package/src/daemon.mjs CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  rewriteStorageState,
55
55
  storageStateIsEmpty,
56
56
  } from "./preview-login.mjs";
57
+ import { GIT_AUTH_TTL_MS, gitAuthEnv, isGitAuthError } from "./git-auth.mjs";
57
58
  import { describeHarnesses, installHarness, probeHarnessAuth } from "./harnesses.mjs";
58
59
  import { probeHarnessModels } from "./models.mjs";
59
60
  import { decideRestart, decideUpdate, fetchLatestVersion, installVersion, installedVersion, installedVersionOrNull, isNewer } from "./selfupdate.mjs";
@@ -158,6 +159,8 @@ export class BridgeDaemon {
158
159
  this.kaiHome = kaiHome;
159
160
  this.log = log;
160
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();
161
164
  this.realtimeFactory = realtimeFactory;
162
165
  this.running = new Map(); // turnId → { ctrl: AbortController, control?: (obj) => boolean }
163
166
  this.services = new Map(); // sessionId → ServiceRunner (lives across turns)
@@ -547,9 +550,11 @@ export class BridgeDaemon {
547
550
  }
548
551
  }
549
552
 
553
+ /** Merges: the runner pid and the verify artifacts dir arrive after the turn's session meta. */
550
554
  rememberInflight(turnId, meta = {}) {
551
- const rest = this.readInflight().filter((e) => e.turnId !== turnId);
552
- 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 }]);
553
558
  }
554
559
 
555
560
  forgetInflight(turnId) {
@@ -611,11 +616,34 @@ export class BridgeDaemon {
611
616
  }
612
617
  }
613
618
 
614
- /** ServiceRunner hook: every dev-server pid is persisted while it lives. */
615
- 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 } = {}) {
616
625
  if (!Number.isInteger(pid)) return;
617
626
  const rest = this.readPreviewPids().filter((e) => e.pid !== pid);
618
- 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;
619
647
  }
620
648
 
621
649
  /**
@@ -856,7 +884,8 @@ export class BridgeDaemon {
856
884
  * truncation. `companionRemotes` (Server-resolved `{ key: remote }`) is
857
885
  * how non-github companions get cloned; `note` survives onto error writes.
858
886
  */
859
- async previewStart({ sessionId, title, repos, companionRemotes = null }) {
887
+ async previewStart({ sessionId, title, repos, sessionUrl = null, companionRemotes = null }) {
888
+ this.rememberSession(sessionId, { title, sessionUrl, repos });
860
889
  let lastNote = null;
861
890
  const skipped = [];
862
891
  // Every report is also RETURNED: the verify turn boots the preview
@@ -1220,7 +1249,7 @@ export class BridgeDaemon {
1220
1249
  ...(this.describeListener ? { describeListener: this.describeListener } : {}),
1221
1250
  ...(this.previewSettleMs != null ? { settleMs: this.previewSettleMs } : {}),
1222
1251
  onServiceExit: (info) => this.onServiceDied(sessionId, info),
1223
- onProcess: (name, pid, op) => this.trackServicePid(sessionId, name, pid, op),
1252
+ onProcess: (name, pid, op, info) => this.trackServicePid(sessionId, name, pid, op, info),
1224
1253
  });
1225
1254
  this.services.set(sessionId, runner);
1226
1255
  }
@@ -1228,21 +1257,23 @@ export class BridgeDaemon {
1228
1257
  }
1229
1258
 
1230
1259
  /** Map the Server's repo bindings onto local checkouts; throw a readable error when one is missing. */
1231
- bindRepos(turn) {
1260
+ async bindRepos(turn) {
1232
1261
  const bound = [];
1233
1262
  for (const r of turn.repos || []) {
1234
1263
  const group = this.repoGroups.find((g) => g.key === r.key);
1235
1264
  if (!group) throw new Error(`Repository ${r.key} is not checked out on this device.`);
1236
1265
  const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
1237
- const ws = materializeBinding({
1266
+ const ws = await this.withGitAuth(r.key, (gitEnv) => materializeBinding({
1238
1267
  kaiHome: this.kaiHome,
1239
1268
  repo: { name: group.name, primaryPath: group.primary.path, defaultBranch: group.primary.defaultBranch },
1240
1269
  binding: { mode, base: r.base, carryUncommitted: r.carryUncommitted },
1241
1270
  sessionId: turn.sessionId,
1242
1271
  title: turn.title,
1243
- });
1272
+ gitEnv,
1273
+ }));
1244
1274
  bound.push({ key: r.key, ...ws });
1245
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 });
1246
1277
  // Remember the choice per repo (the UI asks once, then sticks).
1247
1278
  this.config.repoModes = { ...(this.config.repoModes || {}), [r.key]: mode };
1248
1279
  }
@@ -1310,11 +1341,13 @@ export class BridgeDaemon {
1310
1341
  // turn — see prepareVerifyTurn / finishVerification.
1311
1342
  const isVerify = turn.agent === "kai-verifier";
1312
1343
  let verify = null;
1313
- 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 });
1314
1347
  const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
1315
1348
  try {
1316
1349
  const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === turn.profileId) ?? { id: "gleap-key", kind: "gleap-key", harness: turn.harness };
1317
- const bound = this.bindRepos(turn);
1350
+ const bound = await this.bindRepos(turn);
1318
1351
  // Multi-repo: the runner's cwd is the first repo; the others are
1319
1352
  // reachable as siblings under the same worktree root or by their
1320
1353
  // local paths — the prompt lists them.
@@ -1342,7 +1375,7 @@ export class BridgeDaemon {
1342
1375
  }
1343
1376
  previewNote = verify.note;
1344
1377
  // The evidence dir is where a restart mid-run finds partial footage.
1345
- this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent, artifactsDir: verify.artifactsDir });
1378
+ this.rememberInflight(turnId, { artifactsDir: verify.artifactsDir });
1346
1379
  mcpServers = [
1347
1380
  ...(turn.mcpServers || []),
1348
1381
  previewMcpServer(RUNNER_DIR, {
@@ -1377,6 +1410,7 @@ export class BridgeDaemon {
1377
1410
  signal: ctrl.signal,
1378
1411
  onSpawn: (handle) => {
1379
1412
  entry.control = handle.control;
1413
+ if (Number.isInteger(handle.pid)) this.rememberInflight(turnId, { pid: handle.pid });
1380
1414
  },
1381
1415
  onEvent: (ev) => {
1382
1416
  if (verify) {
@@ -1401,7 +1435,7 @@ export class BridgeDaemon {
1401
1435
  // Plan and verify turns are read-only: worktrees are restored, local
1402
1436
  // checkouts only reported, nothing is ever committed or pushed.
1403
1437
  const readOnlyTurn = !!turn.planMode || isVerify;
1404
- const changes = [...bound, ...this.adoptSessionWorktrees(turn, bound)].map((b) => {
1438
+ const changes = await Promise.all([...bound, ...this.adoptSessionWorktrees(turn, bound)].map(async (b) => {
1405
1439
  ensureCommitExcludes(b.cwd, { allowDevConfig: !!turn.allowDevConfig });
1406
1440
  // A read-only turn must leave the worktree as it found it — see
1407
1441
  // discardChanges. Local checkouts are the user's; only report.
@@ -1423,12 +1457,20 @@ export class BridgeDaemon {
1423
1457
  // Build turns in worktree mode publish the session branch so the
1424
1458
  // Server can open the PR; read-only turns and local mode never push.
1425
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
+ };
1426
1472
  const push = shouldPush
1427
- ? commitAndPush(b.cwd, {
1428
- branch: b.branch,
1429
- allowDevConfig: !!turn.allowDevConfig,
1430
- message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}`,
1431
- })
1473
+ ? await this.withGitAuth(b.key, pushOnce).catch((err) => err.push ?? { committed: false, pushed: false, branch: b.branch, error: err.message })
1432
1474
  : isVerify
1433
1475
  ? false
1434
1476
  : null;
@@ -1438,7 +1480,7 @@ export class BridgeDaemon {
1438
1480
  // Server can write a real pull request description.
1439
1481
  const described = push?.pushed ? describeBranchChanges(b.cwd, b.base) : null;
1440
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 : {}) };
1441
- });
1483
+ }));
1442
1484
  // The read-only notices above were queued AFTER the post-turn
1443
1485
  // flush; land them before the result closes the turn (the Server
1444
1486
  // answers 410 for events on an ended turn).
@@ -1462,9 +1504,11 @@ export class BridgeDaemon {
1462
1504
  profileId: profile.id,
1463
1505
  };
1464
1506
  } catch (err) {
1465
- 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 } : {}) });
1466
1508
  await batcher.flush().catch(() => {});
1467
- 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 } } : {}) };
1468
1512
  } finally {
1469
1513
  // One report, retried until it lands — a dropped result is what
1470
1514
  // leaves a session spinning forever in the dashboard.
@@ -1670,7 +1714,7 @@ export class BridgeDaemon {
1670
1714
  this.previewStatusListeners.set(sessionId, heartbeat.note);
1671
1715
  let preview;
1672
1716
  try {
1673
- 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 });
1674
1718
  } finally {
1675
1719
  this.previewStatusListeners?.delete(sessionId);
1676
1720
  heartbeat.stop();
@@ -2390,6 +2434,43 @@ export class BridgeDaemon {
2390
2434
  return { note: notes.join(""), hasLivePreview: previews.length > 0 };
2391
2435
  }
2392
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
+
2393
2474
  /**
2394
2475
  * Clone `remote` into the preferred root as `name`. Hardened for
2395
2476
  * unattended runs: no credential prompts (`GIT_TERMINAL_PROMPT=0`,
@@ -2401,7 +2482,7 @@ export class BridgeDaemon {
2401
2482
  async cloneRepo({ commandId, remote, name, repoKey, timeoutMs = CLONE_TIMEOUT_MS }) {
2402
2483
  const root = preferredCloneRoot(this.repoGroups, (this.config.roots || [])[0] ?? defaultRoots()[0]);
2403
2484
  try {
2404
- const target = await cloneRepository({ remote, name, root, repoKey, timeoutMs });
2485
+ const target = await this.withGitAuth(repoKey, (gitEnv) => cloneRepository({ remote, name, root, repoKey, timeoutMs, gitEnv }));
2405
2486
  await this.scanRepos();
2406
2487
  // Ack only after the Server has the new inventory: recovery checks
2407
2488
  // readiness before automatically retrying the blocked session.
package/src/executor.mjs CHANGED
@@ -151,6 +151,8 @@ export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, onS
151
151
  const child = spawn(process.execPath, [RUNNER, ...args], { cwd: workDir, env, stdio: ["pipe", "pipe", "pipe"] });
152
152
  child.stdin.on("error", () => {});
153
153
  onSpawn?.({
154
+ /** The runner's pid — the daemon records it for `kai-bridge ps`. */
155
+ pid: child.pid,
154
156
  /** Write one control line; false when the runner is already gone. */
155
157
  control(obj) {
156
158
  if (child.exitCode !== null || child.killed || !child.stdin.writable) return false;
@@ -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;
package/src/preview.mjs CHANGED
@@ -414,7 +414,7 @@ export class ServiceRunner {
414
414
  * `describeListener(port)` (ports.mjs by default) decides adoption;
415
415
  * `onServiceExit({ name, code, repoRoot, repoKey, logPath, error, errorCode, detail })`
416
416
  * fires when a service that had become ready dies on its own;
417
- * `onProcess(name, pid, "add" | "remove")` lets the daemon persist pids.
417
+ * `onProcess(name, pid, "add" | "remove", { port })` lets the daemon persist pids.
418
418
  */
419
419
  constructor({ kaiHome, sessionId, log = () => {}, onStatus = () => {}, preferredPort = null, describeListener = null, settleMs = DEFAULT_SETTLE_MS, onServiceExit = () => {}, onProcess = () => {}, home = homedir() } = {}) {
420
420
  if (settleMs === DEFAULT_SETTLE_MS) settleMs = defaultSettleMs();
@@ -629,7 +629,7 @@ export class ServiceRunner {
629
629
  ).catch(() => {});
630
630
  });
631
631
  this.processes.set(name, child);
632
- this.onProcess(name, child.pid, "add");
632
+ this.onProcess(name, child.pid, "add", { port });
633
633
  this.onStatus(`Starting ${name} (${cmd}) on :${port}`);
634
634
  // Bail as soon as the process dies (command not found, crash on
635
635
  // boot) instead of polling a dead port for the full timeout.
package/src/ps.mjs ADDED
@@ -0,0 +1,152 @@
1
+ // `kai-bridge ps` — what this machine is running right now, grouped by
2
+ // Kai Code session. Read-only: it never talks to the daemon, it reads
3
+ // the files the daemon keeps for crash recovery (daemon.mjs):
4
+ //
5
+ // ~/.kai/daemon.lock pid of the running daemon
6
+ // ~/.kai/state/inflight.json turns in progress (+ runner pid, session meta)
7
+ // ~/.kai/state/preview-pids.json dev-server processes (+ port, session meta)
8
+ //
9
+ // Liveness is `kill -0`. Dead pids are LABELLED, never cleaned up — the
10
+ // daemon owns those files (reportInterruptedTurns / adoptPreviewPids).
11
+ // `collectProcessList` is the one source of truth for the text view,
12
+ // `--json`, and any host UI (a menu-bar widget) that wants the same list.
13
+
14
+ import { readFileSync, statSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ import { KAI_HOME } from "./config.mjs";
18
+ import { installedVersionOrNull } from "./selfupdate.mjs";
19
+
20
+ export function pidAlive(pid) {
21
+ if (!Number.isInteger(pid) || pid <= 0) return false;
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ } catch (err) {
26
+ // EPERM: the process exists but belongs to someone else — still alive.
27
+ return err?.code === "EPERM";
28
+ }
29
+ }
30
+
31
+ function readJson(path, fallback) {
32
+ try {
33
+ return JSON.parse(readFileSync(path, "utf8"));
34
+ } catch {
35
+ return fallback;
36
+ }
37
+ }
38
+
39
+ function readDaemon(kaiHome, isAlive, version) {
40
+ const lock = join(kaiHome, "daemon.lock");
41
+ let pid = null;
42
+ let startedAt = null;
43
+ try {
44
+ pid = Number(readFileSync(lock, "utf8").trim()) || null;
45
+ // The lock is written once, when the daemon starts (acquireLock).
46
+ startedAt = statSync(lock).mtime.toISOString();
47
+ } catch {
48
+ /* no lock — no daemon */
49
+ }
50
+ const running = pid != null && isAlive(pid);
51
+ return { running, pid, version, startedAt: running ? startedAt : null };
52
+ }
53
+
54
+ /** Pre-0.5.0 inflight files held bare turn ids. */
55
+ function normaliseInflight(raw) {
56
+ return (Array.isArray(raw) ? raw : []).map((e) => (typeof e === "string" ? { turnId: e } : e)).filter((e) => e && typeof e.turnId === "string");
57
+ }
58
+
59
+ /**
60
+ * `{ daemon: { running, pid, version, startedAt }, sessions: [{ sessionId,
61
+ * title, sessionUrl, repos, turns: [...], services: [...] }] }`.
62
+ * Turn state: running | exited (runner pid gone, daemon still up) |
63
+ * interrupted (daemon down). Service state: running | dead.
64
+ */
65
+ export function collectProcessList({ kaiHome = KAI_HOME, isAlive = pidAlive, version = installedVersionOrNull() } = {}) {
66
+ const daemon = readDaemon(kaiHome, isAlive, version);
67
+ const inflight = normaliseInflight(readJson(join(kaiHome, "state", "inflight.json"), []));
68
+ const previewPids = readJson(join(kaiHome, "state", "preview-pids.json"), []);
69
+ const services = (Array.isArray(previewPids) ? previewPids : []).filter((e) => e && Number.isInteger(e.pid));
70
+
71
+ const sessions = new Map(); // sessionId (or null) → session block, in first-seen order
72
+ const sessionFor = (entry) => {
73
+ const id = typeof entry.sessionId === "string" ? entry.sessionId : null;
74
+ let s = sessions.get(id);
75
+ if (!s) {
76
+ s = { sessionId: id, title: null, sessionUrl: null, repos: [], turns: [], services: [] };
77
+ sessions.set(id, s);
78
+ }
79
+ // Every entry denormalises the session meta; the first one that has it wins.
80
+ if (!s.title && typeof entry.title === "string") s.title = entry.title;
81
+ if (!s.sessionUrl && typeof entry.sessionUrl === "string") s.sessionUrl = entry.sessionUrl;
82
+ if (!s.repos.length && Array.isArray(entry.repos)) s.repos = entry.repos.filter((r) => typeof r === "string");
83
+ return s;
84
+ };
85
+
86
+ for (const e of inflight) {
87
+ const pid = Number.isInteger(e.pid) ? e.pid : null;
88
+ const state = !daemon.running ? "interrupted" : pid != null && !isAlive(pid) ? "exited" : "running";
89
+ sessionFor(e).turns.push({ turnId: e.turnId, agent: e.agent ?? null, harness: e.harness ?? null, profileId: e.profileId ?? null, pid, startedAt: e.startedAt ?? null, state });
90
+ }
91
+ for (const e of services) {
92
+ sessionFor(e).services.push({ name: e.name ?? null, pid: e.pid, port: Number.isInteger(e.port) ? e.port : null, startedAt: e.startedAt ?? null, state: isAlive(e.pid) ? "running" : "dead" });
93
+ }
94
+ return { daemon, sessions: [...sessions.values()] };
95
+ }
96
+
97
+ // ── text view ───────────────────────────────────────────────────────
98
+
99
+ /** "2h 13m", "3m 12s", "45s" — how long since `iso`. */
100
+ export function formatAge(iso, now = Date.now()) {
101
+ const ms = iso ? now - Date.parse(iso) : NaN;
102
+ if (!Number.isFinite(ms) || ms < 0) return "?";
103
+ const s = Math.floor(ms / 1000);
104
+ if (s < 60) return `${s}s`;
105
+ const m = Math.floor(s / 60);
106
+ if (m < 60) return `${m}m ${s % 60}s`;
107
+ const h = Math.floor(m / 60);
108
+ if (h < 24) return `${h}h ${m % 60}m`;
109
+ return `${Math.floor(h / 24)}d ${h % 24}h`;
110
+ }
111
+
112
+ /** Minutes-and-up version for long-lived dev servers ("14m", "2h 13m"). */
113
+ function formatUptime(iso, now) {
114
+ const age = formatAge(iso, now);
115
+ return age.endsWith("s") && !age.includes("m") ? age : age.replace(/ \d+s$/, "");
116
+ }
117
+
118
+ function turnLine(t, now) {
119
+ const who = [t.agent ?? "turn", t.harness && t.profileId ? `${t.harness}/${t.profileId}` : t.harness ?? ""].filter(Boolean);
120
+ const state =
121
+ t.state === "running" ? `running ${formatAge(t.startedAt, now)}` : t.state === "exited" ? "exited — runner gone, daemon still winding it down" : "interrupted — daemon is down";
122
+ return ` turn ${t.turnId.padEnd(26)} ${who[0].padEnd(16)} ${(who[1] ?? "").padEnd(16)} ${(t.pid != null ? `pid ${t.pid}` : "").padEnd(10)} ${state}`;
123
+ }
124
+
125
+ function serviceLine(s, now) {
126
+ const state = s.state === "running" ? `up ${formatUptime(s.startedAt, now)}` : "dead — stale entry";
127
+ return ` dev ${(s.name ?? "?").padEnd(26)} ${"".padEnd(16)} ${"".padEnd(16)} ${`pid ${s.pid}`.padEnd(10)} ${(s.port != null ? `:${s.port}` : "").padEnd(7)} ${state}`;
128
+ }
129
+
130
+ export function formatProcessList(list, { now = Date.now() } = {}) {
131
+ const lines = [];
132
+ const d = list.daemon;
133
+ lines.push(
134
+ d.running
135
+ ? `daemon: running · pid ${d.pid}${d.version ? ` · v${d.version}` : ""} · up ${formatUptime(d.startedAt, now)}`
136
+ : `daemon: not running${d.pid ? ` (stale lock, pid ${d.pid})` : ""} — run \`kai-bridge start\` or \`kai-bridge install\``,
137
+ );
138
+ if (!list.sessions.length) {
139
+ lines.push("", "nothing running on this machine");
140
+ return lines.join("\n");
141
+ }
142
+ for (const s of list.sessions) {
143
+ lines.push("");
144
+ const head = [`session ${s.sessionId ?? "(unknown)"}`, s.title ? `"${s.title}"` : "(untitled)"];
145
+ if (s.repos.length) head.push(s.repos.join(", "));
146
+ lines.push(head.join(" "));
147
+ if (s.sessionUrl) lines.push(` ${s.sessionUrl}`);
148
+ for (const t of s.turns) lines.push(turnLine(t, now));
149
+ for (const sv of s.services) lines.push(serviceLine(sv, now));
150
+ }
151
+ return lines.join("\n");
152
+ }
@@ -27,7 +27,7 @@ export function locateRepository(rawPath, repoKey) {
27
27
  return target;
28
28
  }
29
29
 
30
- export function cloneRepository({ remote, name, root, repoKey, timeoutMs = 180_000 }, spawnGit = spawn) {
30
+ export function cloneRepository({ remote, name, root, repoKey, timeoutMs = 180_000, gitEnv = null }, spawnGit = spawn) {
31
31
  if (typeof remote !== 'string' || !(/^(https?|ssh):\/\/\S+$/i.test(remote) || /^[\w.-]+@[\w.-]+:\S+$/.test(remote))) {
32
32
  return Promise.reject(new Error('This repository has an unsupported clone URL.'));
33
33
  }
@@ -53,7 +53,7 @@ export function cloneRepository({ remote, name, root, repoKey, timeoutMs = 180_0
53
53
  stdio: ['ignore', 'ignore', 'pipe'],
54
54
  detached: process.platform !== 'win32',
55
55
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo', SSH_ASKPASS: 'echo',
56
- GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes' },
56
+ GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes', ...(gitEnv || {}) },
57
57
  });
58
58
  let stderr = '';
59
59
  let timedOut = false;
package/src/workspace.mjs CHANGED
@@ -22,6 +22,89 @@ function git(cwd, args, opts = {}) {
22
22
  return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...opts }).trim();
23
23
  }
24
24
 
25
+ /** Network ops only: `gitEnv` is the git-auth.mjs fallback, applied to that one command. */
26
+ const withGitEnv = (gitEnv, opts = {}) => (gitEnv ? { ...opts, env: { ...process.env, ...gitEnv } } : opts);
27
+
28
+ /**
29
+ * A workspace could not be prepared for a reason that has nothing to do
30
+ * with the task — the Server surfaces `code` as a one-click retry instead
31
+ * of a dead session. `repo` names the checkout for the log line.
32
+ */
33
+ export class WorkspaceError extends Error {
34
+ constructor(message, { code, repo, cause } = {}) {
35
+ super(message, cause ? { cause } : undefined);
36
+ this.name = "WorkspaceError";
37
+ this.code = code;
38
+ this.repo = repo;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Git refused to update a ref because ANOTHER git process was writing the
44
+ * same repo at that moment: the bridge fetches `origin/<base>` in the
45
+ * user's primary checkout, and IDE auto-fetch / a second session /
46
+ * the user's own `git pull` race it there. The loser sees one of:
47
+ *
48
+ * cannot lock ref 'refs/remotes/origin/master': is at <new> but expected <old>
49
+ * Unable to create '…/refs/remotes/origin/master.lock': File exists.
50
+ * Another git process seems to be running in this repository
51
+ *
52
+ * None of them mean anything is wrong — the ref is simply being moved by
53
+ * someone else — so the fetch is retried, and if it keeps losing the
54
+ * ref the competitor just wrote is used as the base (2026-09-16: a
55
+ * session on ticket #147312 died 54 s in on exactly this, before the
56
+ * agent ever ran).
57
+ */
58
+ export function isRefLockContention(message) {
59
+ const text = String(message || "");
60
+ return (
61
+ /cannot lock ref/i.test(text) ||
62
+ /\.lock['"]?: File exists/i.test(text) ||
63
+ /Another git process seems to be running/i.test(text)
64
+ );
65
+ }
66
+
67
+ const sleepSync = (ms) => {
68
+ if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
69
+ };
70
+
71
+ /**
72
+ * `git fetch origin <base>` in `primaryPath`, tolerant of ref-lock
73
+ * contention. Returns `{ attempts, stale }` — `stale: true` means every
74
+ * attempt lost the race and the existing `origin/<base>` (which the
75
+ * competitor just updated) is used instead. Any other fetch failure, or
76
+ * contention with no usable `origin/<base>`, throws a WorkspaceError
77
+ * whose `code` the Server turns into a retry offer.
78
+ */
79
+ export function fetchBase(primaryPath, base, { exec = git, attempts = 4, backoffMs = 400, sleep = sleepSync, repo = primaryPath, gitEnv = null } = {}) {
80
+ let lastError = null;
81
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
82
+ try {
83
+ exec(primaryPath, ["fetch", "origin", base, "--quiet"], withGitEnv(gitEnv));
84
+ return { attempts: attempt, stale: false };
85
+ } catch (err) {
86
+ const text = `${err?.stderr || ""}\n${err?.message || ""}`;
87
+ if (!isRefLockContention(text)) {
88
+ throw new WorkspaceError(err?.message || String(err), { code: "workspace_fetch_failed", repo, cause: err });
89
+ }
90
+ lastError = err;
91
+ if (attempt < attempts) sleep(backoffMs * attempt);
92
+ }
93
+ }
94
+ // Every attempt lost: whoever kept winning has already moved
95
+ // origin/<base> forward, so it is at least as fresh as our fetch
96
+ // would have made it.
97
+ try {
98
+ exec(primaryPath, ["rev-parse", "--verify", "--quiet", `origin/${base}^{commit}`]);
99
+ return { attempts, stale: true };
100
+ } catch {
101
+ throw new WorkspaceError(
102
+ `Git in ${repo} was busy (another fetch was running) and origin/${base} is not available yet — retry the task.`,
103
+ { code: "workspace_transient", repo, cause: lastError },
104
+ );
105
+ }
106
+ }
107
+
25
108
  export function sessionSlug(sessionId, title) {
26
109
  const t = String(title || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
27
110
  const id = String(sessionId || "").slice(-8);
@@ -67,7 +150,7 @@ export function copyPrimaryEnvFiles(primaryPath, cwd) {
67
150
  * Materialise one repo binding. Returns `{ cwd, mode, branch, base }`.
68
151
  * `repo` = `{ name, primaryPath, defaultBranch }`, `binding` = `{ mode, base?, carryUncommitted? }`.
69
152
  */
70
- export function materializeBinding({ kaiHome, repo, binding, sessionId, title, branchPrefix = "kai" }) {
153
+ export function materializeBinding({ kaiHome, repo, binding, sessionId, title, branchPrefix = "kai", fetch = fetchBase, gitEnv = null }) {
71
154
  const mode = binding?.mode === "local" ? "local" : "worktree";
72
155
  if (mode === "local") {
73
156
  const branch = git(repo.primaryPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
@@ -82,7 +165,7 @@ export function materializeBinding({ kaiHome, repo, binding, sessionId, title, b
82
165
  return { cwd: dir, mode, branch, base, resumed: true };
83
166
  }
84
167
  mkdirSync(dirname(dir), { recursive: true });
85
- git(repo.primaryPath, ["fetch", "origin", base, "--quiet"]);
168
+ const fetched = fetch(repo.primaryPath, base, { repo: repo.name, gitEnv });
86
169
  git(repo.primaryPath, ["worktree", "add", "-b", branch, dir, `origin/${base}`]);
87
170
  // A fresh worktree has no node_modules; clone the primary checkout's
88
171
  // when the lockfiles match so the agent's tests and the preview boot
@@ -110,7 +193,7 @@ export function materializeBinding({ kaiHome, repo, binding, sessionId, title, b
110
193
  }
111
194
  }
112
195
  }
113
- return { cwd: dir, mode, branch, base, resumed: false, deps };
196
+ return { cwd: dir, mode, branch, base, resumed: false, deps, fetch: fetched };
114
197
  }
115
198
 
116
199
  /** The branch checked out at `cwd` (null when it is not a git checkout). */
@@ -267,7 +350,7 @@ export function ensureCommitExcludes(cwd, { allowDevConfig = false } = {}) {
267
350
  }
268
351
  }
269
352
 
270
- export function commitAndPush(cwd, { branch, message, allowEmpty = false, allowDevConfig = false } = {}) {
353
+ export function commitAndPush(cwd, { branch, message, allowEmpty = false, allowDevConfig = false, gitEnv = null } = {}) {
271
354
  const out = { committed: false, pushed: false, branch, commitSha: null, remote: null, error: null };
272
355
  try {
273
356
  ensureCommitExcludes(cwd, { allowDevConfig });
@@ -279,7 +362,7 @@ export function commitAndPush(cwd, { branch, message, allowEmpty = false, allowD
279
362
  }
280
363
  out.commitSha = git(cwd, ["rev-parse", "HEAD"]);
281
364
  out.remote = git(cwd, ["remote", "get-url", "origin"]);
282
- git(cwd, ["push", "-u", "origin", `HEAD:${branch}`], { timeout: 120_000 });
365
+ git(cwd, ["push", "-u", "origin", `HEAD:${branch}`], withGitEnv(gitEnv, { timeout: 120_000 }));
283
366
  out.pushed = true;
284
367
  } catch (err) {
285
368
  out.error = String(err?.stderr || err?.message || err).trim().slice(0, 500);