@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 +1 -0
- package/bin/kai-bridge.mjs +12 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/api.mjs +8 -0
- package/src/daemon.mjs +105 -24
- package/src/executor.mjs +2 -0
- package/src/git-auth.mjs +38 -0
- package/src/preview.mjs +2 -2
- package/src/ps.mjs +152 -0
- package/src/repository-setup.mjs +2 -2
- package/src/workspace.mjs +88 -5
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
|
package/bin/kai-bridge.mjs
CHANGED
|
@@ -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>
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleapai/kai-bridge",
|
|
3
|
-
"version": "0.9.
|
|
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.
|
|
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.
|
|
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
|
|
552
|
-
|
|
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
|
-
/**
|
|
615
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
?
|
|
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
|
-
|
|
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;
|
package/src/git-auth.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/repository-setup.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|