@gleapai/kai-bridge 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@gleapai/kai-bridge",
9
- "version": "0.11.0",
9
+ "version": "0.12.0",
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.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Kai Code Bridge runs Kai Code on your computer or server with your own coding subscriptions and local previews.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/api.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // POST /gleapcode/bridge/pair/poll → { status, device?, token? } (pollToken)
5
5
  // PUT /gleapcode/bridge/devices/me/hello { name, platform, version, profiles, repos, roots }
6
6
  // POST /gleapcode/bridge/devices/me/heartbeat { running: [turnIds] }
7
+ // POST /gleapcode/bridge/turns/:id/ack { via } → 410 when the turn already ended
7
8
  // POST /gleapcode/bridge/turns/:id/events { events: [contract lines] }
8
9
  // POST /gleapcode/bridge/turns/:id/result { result, changes, status }
9
10
  // POST /gleapcode/bridge/devices/me/public-hosts { sessionId, services } → { domain, hosts, tunnel, displaced }
@@ -96,6 +97,15 @@ export class BridgeApi {
96
97
  heartbeat(payload) {
97
98
  return this.request("POST", "/gleapcode/bridge/devices/me/heartbeat", payload);
98
99
  }
100
+ /**
101
+ * "This machine has the turn" — sent for every delivery of a
102
+ * `bridge.turn.start` (push, the Server's re-send, the pending poll); the
103
+ * Server stops re-sending once one lands. Short timeout: the turn does not
104
+ * wait on a slow Server. 410 = the turn ended before it got here.
105
+ */
106
+ turnAck(turnId, payload) {
107
+ return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/ack`, payload, { timeoutMs: 5_000 });
108
+ }
99
109
  turnEvents(turnId, events) {
100
110
  return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/events`, { events });
101
111
  }
package/src/daemon.mjs CHANGED
@@ -5,7 +5,9 @@ import { cloneRepository, locateRepository } from './repository-setup.mjs';
5
5
  //
6
6
  // Commands arrive on `private-bridge-<deviceId>` (Sockudo / Pusher
7
7
  // protocol, the same channel family the dashboard uses):
8
- // bridge.turn.start { commandId, turnId, sessionId, profileId, repos:[{key, mode, base, carryUncommitted}], ...AgentRunOpts }
8
+ // bridge.turn.start { turnId, sessionId, profileId, repos:[{key, mode, base, carryUncommitted}], resend?, ...AgentRunOpts }
9
+ // — acknowledged with POST /turns/:id/ack; the Server re-sends an
10
+ // unacknowledged start (`resend: n`), so a turn id seen before is ignored
9
11
  // bridge.turn.cancel { turnId }
10
12
  // bridge.turn.steer { turnId, steer: { id, text } } — inject into the running turn;
11
13
  // answered with a {type:"steer", id, outcome} turn event
@@ -57,6 +59,8 @@ const REALTIME_RETRY_MS = 15_000;
57
59
  const HEARTBEAT_MS = 30_000;
58
60
  /** Safety net under the realtime channel: ask the server for work it thinks we run (see pullPendingWork). */
59
61
  const PENDING_POLL_MS = 60_000;
62
+ /** Turn ids remembered after they ran, so a late duplicate delivery never runs twice (see startTurn). */
63
+ const HANDLED_TURNS_MAX = 200;
60
64
  const USAGE_REFRESH_MS = 10 * 60_000;
61
65
  // Harness model catalogues change on releases, not by the minute.
62
66
  const MODELS_REFRESH_MS = 6 * 60 * 60_000;
@@ -563,6 +567,42 @@ export class BridgeDaemon {
563
567
  this.writeInflight(this.readInflight().filter((e) => e.turnId !== turnId));
564
568
  }
565
569
 
570
+ get handledTurnsPath() {
571
+ return join(this.kaiHome, "state", "handled-turns.json");
572
+ }
573
+
574
+ /**
575
+ * Turn ids this machine already ran (newest last, capped). Kept on disk:
576
+ * a turn whose result was lost is still "running" on the Server, and the
577
+ * pending poll after a restart must not run it a second time.
578
+ */
579
+ handledTurns() {
580
+ if (!this.handled) {
581
+ let ids = [];
582
+ try {
583
+ const raw = JSON.parse(readFileSync(this.handledTurnsPath, "utf8"));
584
+ if (Array.isArray(raw)) ids = raw.filter((id) => typeof id === "string");
585
+ } catch {
586
+ /* none yet */
587
+ }
588
+ this.handled = new Set(ids.slice(-HANDLED_TURNS_MAX));
589
+ }
590
+ return this.handled;
591
+ }
592
+
593
+ rememberHandledTurn(turnId) {
594
+ const handled = this.handledTurns();
595
+ handled.delete(turnId);
596
+ handled.add(turnId);
597
+ while (handled.size > HANDLED_TURNS_MAX) handled.delete(handled.values().next().value);
598
+ try {
599
+ mkdirSync(join(this.kaiHome, "state"), { recursive: true });
600
+ writeFileSync(this.handledTurnsPath, JSON.stringify([...handled]));
601
+ } catch {
602
+ /* best effort */
603
+ }
604
+ }
605
+
566
606
  /** Turns this machine was running when it was killed — report them dead. */
567
607
  async reportInterruptedTurns() {
568
608
  const entries = this.readInflight();
@@ -571,6 +611,8 @@ export class BridgeDaemon {
571
611
  for (const entry of entries) {
572
612
  const { turnId } = entry;
573
613
  this.log("warn", "turn.interrupted", { turnId, agent: entry.agent });
614
+ // Reported failed below; a re-send or a poll must not start it over.
615
+ this.rememberHandledTurn(turnId);
574
616
  await this.api
575
617
  .turnResult(turnId, {
576
618
  status: "failed",
@@ -867,8 +909,11 @@ export class BridgeDaemon {
867
909
  }
868
910
  for (const turn of pending?.turns || []) {
869
911
  if (this.running.has(turn.turnId) || cancelled.has(String(turn.turnId))) continue;
912
+ // Ran here already and its result never landed: the Server's reaper
913
+ // settles it — running the work again is never the fix.
914
+ if (this.handledTurns().has(turn.turnId)) continue;
870
915
  this.log("info", "turn.recovered", { turnId: turn.turnId, via });
871
- void this.startTurn(turn).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
916
+ void this.startTurn(turn, { via }).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
872
917
  }
873
918
  } catch (err) {
874
919
  // Older server without the endpoint, or the server is restarting: the
@@ -931,7 +976,7 @@ export class BridgeDaemon {
931
976
  .catch((err) => this.log("warn", "update.turn.refused.failed", { turnId: data.turnId, error: err?.message }));
932
977
  return;
933
978
  }
934
- return this.startTurn(data);
979
+ return this.startTurn(data, { via: data?.resend ? "resend" : "push" });
935
980
  case "bridge.turn.cancel":
936
981
  this.running.get(data.turnId)?.ctrl.abort();
937
982
  return;
@@ -1665,9 +1710,10 @@ export class BridgeDaemon {
1665
1710
  }
1666
1711
 
1667
1712
  /** Map the Server's repo bindings onto local checkouts; throw a readable error when one is missing. */
1668
- async bindRepos(turn) {
1713
+ async bindRepos(turn, { onPrepare, signal } = {}) {
1669
1714
  const bound = [];
1670
1715
  for (const r of turn.repos || []) {
1716
+ if (signal?.aborted) break; // stopped mid-prep: the caller reports the cancel
1671
1717
  const group = this.repoGroups.find((g) => g.key === r.key);
1672
1718
  if (!group) throw new Error(`Repository ${r.key} is not checked out on this device.`);
1673
1719
  const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
@@ -1678,6 +1724,7 @@ export class BridgeDaemon {
1678
1724
  sessionId: turn.sessionId,
1679
1725
  title: turn.title,
1680
1726
  gitEnv,
1727
+ onPrepare,
1681
1728
  }));
1682
1729
  bound.push({ key: r.key, ...ws });
1683
1730
  if (ws.deps) this.log("info", "deps.seed", { repo: r.key, ...ws.deps });
@@ -1736,11 +1783,25 @@ export class BridgeDaemon {
1736
1783
  return adopted;
1737
1784
  }
1738
1785
 
1739
- async startTurn(turn) {
1786
+ /**
1787
+ * `via` = how the turn reached us: push, resend (the Server re-sent an
1788
+ * unacknowledged start), poll or reconnect (pullPendingWork).
1789
+ */
1790
+ async startTurn(turn, { via = "push" } = {}) {
1740
1791
  const { turnId } = turn;
1741
- if (this.running.has(turnId)) return;
1792
+ // One turn, several deliveries by design: the Server re-sends a start
1793
+ // until it is acknowledged, and the poll / reconnect pull replays what
1794
+ // the Server still expects. Only the first delivery runs. Every one is
1795
+ // acknowledged — a duplicate usually means the first ack was missed.
1796
+ if (this.running.has(turnId) || this.handledTurns().has(turnId)) {
1797
+ this.log("info", "turn.duplicate", { turnId, via, running: this.running.has(turnId) });
1798
+ await this.ackTurn(turnId, via);
1799
+ return;
1800
+ }
1742
1801
  const ctrl = new AbortController();
1743
1802
  const entry = { ctrl, control: null, sessionId: turn.sessionId };
1803
+ // Claimed before the first await, so a second delivery arriving while
1804
+ // the ack is in flight is the duplicate above.
1744
1805
  this.running.set(turnId, entry);
1745
1806
  const releaseAwake = keepAwake();
1746
1807
  let outcome = null;
@@ -1749,8 +1810,39 @@ export class BridgeDaemon {
1749
1810
  this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null, harness: turn.harness ?? null, profileId: turn.profileId ?? null, startedAt: new Date().toISOString(), ...session });
1750
1811
  const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
1751
1812
  try {
1813
+ // Receipt first: the Server stops re-sending the start.
1814
+ if ((await this.ackTurn(turnId, via)) === "ended") {
1815
+ // Stopped or reaped before it reached this machine — nobody is
1816
+ // waiting for it, and its result would be refused anyway.
1817
+ this.log("info", "turn.ended_before_start", { turnId, via });
1818
+ return;
1819
+ }
1752
1820
  const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === turn.profileId) ?? { id: "gleap-key", kind: "gleap-key", harness: turn.harness };
1753
- const bound = await this.bindRepos(turn);
1821
+ // Prep no longer blocks the daemon, so a Stop (or session close) can
1822
+ // land before the runner exists — and runTurn's abort listener would
1823
+ // never hear a signal that fired before it was added. Never start the
1824
+ // agent for a turn that was stopped on the way.
1825
+ const stoppedBeforeRun = () => {
1826
+ if (!ctrl.signal.aborted) return false;
1827
+ this.log("info", "turn.cancelled_before_run", { turnId });
1828
+ outcome = { status: "cancelled", changes: [], profileId: profile.id };
1829
+ return true;
1830
+ };
1831
+ if (stoppedBeforeRun()) return;
1832
+ // A fresh worktree costs a fetch, a checkout and a node_modules clone
1833
+ // — up to minutes for a big repo — while the dashboard already calls
1834
+ // the session running. Say what is happening before it starts (once
1835
+ // per turn; resumed worktrees and local checkouts skip it).
1836
+ let preparing = null;
1837
+ const onPrepare = () => {
1838
+ preparing ??= (async () => {
1839
+ batcher.push({ type: "tool_status", message: `Preparing workspace on ${this.config.device?.name || "this machine"}…` });
1840
+ await batcher.flush();
1841
+ })();
1842
+ return preparing;
1843
+ };
1844
+ const bound = await this.bindRepos(turn, { onPrepare, signal: ctrl.signal });
1845
+ if (stoppedBeforeRun()) return;
1754
1846
  // Multi-repo: the runner's cwd is the first repo; the others are
1755
1847
  // reachable as siblings under the same worktree root or by their
1756
1848
  // local paths — the prompt lists them.
@@ -1764,6 +1856,7 @@ export class BridgeDaemon {
1764
1856
  const live = await this.describeLivePreview(turn, bound, batcher);
1765
1857
  const previewNote = live.note;
1766
1858
  const mcpServers = live.hasLivePreview ? [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)] : turn.mcpServers;
1859
+ if (stoppedBeforeRun()) return;
1767
1860
  const res = await runTurn({
1768
1861
  turn: { ...turn, task: `${turn.task}${repoNote}${previewNote}`, mcpServers },
1769
1862
  profile,
@@ -1861,12 +1954,30 @@ export class BridgeDaemon {
1861
1954
  .catch((err) => this.log("error", "result.lost", { turnId, error: err.message }));
1862
1955
  }
1863
1956
  this.forgetInflight(turnId);
1957
+ this.rememberHandledTurn(turnId);
1864
1958
  releaseAwake();
1865
1959
  this.running.delete(turnId);
1866
1960
  if ((this.updatePending || this.restartPending) && this.running.size === 0) void this.checkForUpdate();
1867
1961
  }
1868
1962
  }
1869
1963
 
1964
+ /**
1965
+ * Tell the Server this machine has the turn. Resolves "acked", "ended"
1966
+ * (410: the turn is over — it must not run), or "unknown" (an older Server
1967
+ * without the route, a network hiccup): the turn then runs as it always
1968
+ * did. Never throws.
1969
+ */
1970
+ async ackTurn(turnId, via) {
1971
+ try {
1972
+ await this.api.turnAck(turnId, { via });
1973
+ return "acked";
1974
+ } catch (err) {
1975
+ if (err?.status === 410) return "ended";
1976
+ this.log(err?.status === 404 ? "debug" : "warn", "turn.ack.failed", { turnId, via, error: err?.message });
1977
+ return "unknown";
1978
+ }
1979
+ }
1980
+
1870
1981
  // ── preview browser ────────────────────────────────────────────────
1871
1982
  /** Overridable seam (tests, embedded hosts): a browser the warm-up and the Playwright MCP can launch. */
1872
1983
  ensurePreviewBrowser() {
package/src/deps.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  // yet) we leave the directory absent and the install path takes over with
12
12
  // the lockfile-driven, cache-first command instead of a bare `npm install`.
13
13
 
14
- import { execFileSync } from "node:child_process";
14
+ import { spawn } from "node:child_process";
15
15
  import { createHash } from "node:crypto";
16
16
  import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
17
17
  import { platform } from "node:os";
@@ -84,24 +84,45 @@ export function canSeedNodeModules({ primaryPath, cwd }) {
84
84
  return { ok: true, reason: "lockfile_match", pm: b.pm };
85
85
  }
86
86
 
87
+ /**
88
+ * `cmd args` as a promise, output discarded (stderr's head kept for the
89
+ * reason). Asynchronous on purpose: cloning a big `node_modules` takes 30 s
90
+ * to 2.5 min (bridge.log: 144 s for the Gleap dashboard, 105 s for the
91
+ * Server), and a synchronous copy froze the daemon for all of it.
92
+ */
93
+ function runQuiet(cmd, args) {
94
+ return new Promise((resolve, reject) => {
95
+ const child = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
96
+ let stderr = "";
97
+ child.stderr.on("data", (chunk) => {
98
+ if (stderr.length < 2000) stderr += chunk;
99
+ });
100
+ child.on("error", reject);
101
+ child.on("close", (code, signal) => {
102
+ if (code === 0) resolve();
103
+ else reject(new Error(`${cmd} exited with ${code ?? signal}${stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : ""}`));
104
+ });
105
+ });
106
+ }
107
+
87
108
  /**
88
109
  * Copy the primary checkout's `node_modules` into a fresh worktree when
89
- * the lockfiles match. Returns `{ seeded, reason, ms }`; never throws — a
110
+ * the lockfiles match. Resolves `{ seeded, reason, ms }`; never rejects — a
90
111
  * failed or partial copy is removed so the install path still runs.
91
112
  *
92
113
  * macOS `cp -c` clones via clonefile(2) and falls back to a plain copy on
93
114
  * filesystems without it; `-R` without `-L` keeps symlinks as symlinks
94
115
  * (`.bin` shims, pnpm's store links — all relative, so they stay valid).
95
116
  */
96
- export function seedNodeModules({ primaryPath, cwd, os = platform(), exec = execFileSync }) {
117
+ export async function seedNodeModules({ primaryPath, cwd, os = platform(), exec = runQuiet }) {
97
118
  const started = Date.now();
98
119
  const check = canSeedNodeModules({ primaryPath, cwd });
99
120
  if (!check.ok) return { seeded: false, reason: check.reason, ms: 0 };
100
121
  const src = join(primaryPath, "node_modules");
101
122
  const dst = join(cwd, "node_modules");
102
123
  try {
103
- if (os === "darwin") exec("cp", ["-c", "-R", src, dst], { stdio: "ignore" });
104
- else if (os === "linux") exec("cp", ["-R", "--reflink=auto", src, dst], { stdio: "ignore" });
124
+ if (os === "darwin") await exec("cp", ["-c", "-R", src, dst]);
125
+ else if (os === "linux") await exec("cp", ["-R", "--reflink=auto", src, dst]);
105
126
  else return { seeded: false, reason: "unsupported_platform", ms: 0 };
106
127
  return { seeded: true, reason: check.reason, ms: Date.now() - started };
107
128
  } catch (err) {
package/src/workspace.mjs CHANGED
@@ -12,19 +12,64 @@
12
12
  // another a worktree). Git ops ported from the retired desktop runtime's
13
13
  // git-checkout-ops.mjs in spirit: plain `git` CLI, no libraries.
14
14
 
15
- import { execFileSync } from "node:child_process";
15
+ import { execFile, execFileSync } from "node:child_process";
16
16
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
17
17
  import { dirname, join } from "node:path";
18
+ import { promisify } from "node:util";
18
19
 
19
20
  import { seedNodeModules } from "./deps.mjs";
20
21
 
22
+ const execFileAsync = promisify(execFile);
23
+
21
24
  function git(cwd, args, opts = {}) {
22
25
  return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...opts }).trim();
23
26
  }
24
27
 
28
+ /**
29
+ * `git` that leaves the daemon's event loop alone. Preparing a fresh
30
+ * worktree takes minutes on a big repo (fetch, checkout, the node_modules
31
+ * clone); run with execFileSync it froze the whole daemon for that long —
32
+ * no heartbeat (the device could read "away" after 90 s), no realtime pongs, and
33
+ * the next turn's start and the user's Stop sat in the queue until it was
34
+ * done (bridge.log 2026-09-11 and 09-18: a start logged the millisecond a
35
+ * 60-second node_modules clone for another turn finished). stdin is closed
36
+ * right away, like execFileSync's "ignore": nothing may wait on a prompt.
37
+ */
38
+ async function gitAsync(cwd, args, opts = {}) {
39
+ const pending = execFileAsync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, ...opts });
40
+ pending.child.stdin?.end();
41
+ const { stdout } = await pending;
42
+ return String(stdout).trim();
43
+ }
44
+
25
45
  /** Network ops only: `gitEnv` is the git-auth.mjs fallback, applied to that one command. */
26
46
  const withGitEnv = (gitEnv, opts = {}) => (gitEnv ? { ...opts, env: { ...process.env, ...gitEnv } } : opts);
27
47
 
48
+ /**
49
+ * Nobody can answer a prompt from a background daemon: a credential or
50
+ * passphrase prompt fails fast instead of hanging the fetch (git-auth.mjs
51
+ * then offers the Server's credentials) — the same hardening the clone path
52
+ * has. A user's own GIT_SSH_COMMAND wins.
53
+ */
54
+ const nonInteractiveGitEnv = (gitEnv) => ({
55
+ ...process.env,
56
+ GIT_TERMINAL_PROMPT: "0",
57
+ GIT_ASKPASS: "echo",
58
+ SSH_ASKPASS: "echo",
59
+ GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || "ssh -o BatchMode=yes",
60
+ ...(gitEnv || {}),
61
+ });
62
+
63
+ /**
64
+ * The prep fetch had no limit: a black-holed network (captive portal,
65
+ * dropped VPN) left the session "running" with nothing running. A single
66
+ * branch of even a large repo arrives well within this.
67
+ */
68
+ export const FETCH_TIMEOUT_MS = 120_000;
69
+
70
+ /** The child was killed because it ran past its `timeout` (async and sync forms). */
71
+ const isTimeout = (err) => err?.code === "ETIMEDOUT" || (err?.killed === true && err?.signal === "SIGTERM");
72
+
28
73
  /**
29
74
  * A workspace could not be prepared for a reason that has nothing to do
30
75
  * with the task — the Server surfaces `code` as a one-click retry instead
@@ -64,38 +109,43 @@ export function isRefLockContention(message) {
64
109
  );
65
110
  }
66
111
 
67
- const sleepSync = (ms) => {
68
- if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
69
- };
112
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
70
113
 
71
114
  /**
72
115
  * `git fetch origin <base>` in `primaryPath`, tolerant of ref-lock
73
- * contention. Returns `{ attempts, stale }` — `stale: true` means every
116
+ * contention. Resolves `{ attempts, stale }` — `stale: true` means every
74
117
  * 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.
118
+ * competitor just updated) is used instead. Any other fetch failure, a
119
+ * fetch that runs past `timeoutMs`, or contention with no usable
120
+ * `origin/<base>` rejects with a WorkspaceError whose `code` the Server
121
+ * turns into a retry offer.
78
122
  */
79
- export function fetchBase(primaryPath, base, { exec = git, attempts = 4, backoffMs = 400, sleep = sleepSync, repo = primaryPath, gitEnv = null } = {}) {
123
+ export async function fetchBase(primaryPath, base, { exec = gitAsync, attempts = 4, backoffMs = 400, sleep = pause, repo = primaryPath, gitEnv = null, timeoutMs = FETCH_TIMEOUT_MS } = {}) {
80
124
  let lastError = null;
81
125
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
82
126
  try {
83
- exec(primaryPath, ["fetch", "origin", base, "--quiet"], withGitEnv(gitEnv));
127
+ await exec(primaryPath, ["fetch", "origin", base, "--quiet"], { timeout: timeoutMs, env: nonInteractiveGitEnv(gitEnv) });
84
128
  return { attempts: attempt, stale: false };
85
129
  } catch (err) {
130
+ if (isTimeout(err)) {
131
+ throw new WorkspaceError(
132
+ `git fetch origin ${base} in ${repo} did not finish within ${Math.round(timeoutMs / 1000)} s — check this machine's network and Git access, then retry the task.`,
133
+ { code: "workspace_fetch_failed", repo, cause: err },
134
+ );
135
+ }
86
136
  const text = `${err?.stderr || ""}\n${err?.message || ""}`;
87
137
  if (!isRefLockContention(text)) {
88
138
  throw new WorkspaceError(err?.message || String(err), { code: "workspace_fetch_failed", repo, cause: err });
89
139
  }
90
140
  lastError = err;
91
- if (attempt < attempts) sleep(backoffMs * attempt);
141
+ if (attempt < attempts) await sleep(backoffMs * attempt);
92
142
  }
93
143
  }
94
144
  // Every attempt lost: whoever kept winning has already moved
95
145
  // origin/<base> forward, so it is at least as fresh as our fetch
96
146
  // would have made it.
97
147
  try {
98
- exec(primaryPath, ["rev-parse", "--verify", "--quiet", `origin/${base}^{commit}`]);
148
+ await exec(primaryPath, ["rev-parse", "--verify", "--quiet", `origin/${base}^{commit}`]);
99
149
  return { attempts, stale: true };
100
150
  } catch {
101
151
  throw new WorkspaceError(
@@ -147,13 +197,16 @@ export function copyPrimaryEnvFiles(primaryPath, cwd) {
147
197
  }
148
198
 
149
199
  /**
150
- * Materialise one repo binding. Returns `{ cwd, mode, branch, base }`.
200
+ * Materialise one repo binding. Resolves `{ cwd, mode, branch, base }`.
151
201
  * `repo` = `{ name, primaryPath, defaultBranch }`, `binding` = `{ mode, base?, carryUncommitted? }`.
202
+ * `onPrepare({ repo, base })` is awaited once a fresh worktree has to be
203
+ * built — the slow path (fetch, checkout, node_modules) — before any of it
204
+ * starts; resumed worktrees and local checkouts never call it.
152
205
  */
153
- export function materializeBinding({ kaiHome, repo, binding, sessionId, title, branchPrefix = "kai", fetch = fetchBase, gitEnv = null }) {
206
+ export async function materializeBinding({ kaiHome, repo, binding, sessionId, title, branchPrefix = "kai", fetch = fetchBase, gitEnv = null, onPrepare = null }) {
154
207
  const mode = binding?.mode === "local" ? "local" : "worktree";
155
208
  if (mode === "local") {
156
- const branch = git(repo.primaryPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
209
+ const branch = await gitAsync(repo.primaryPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
157
210
  return { cwd: repo.primaryPath, mode, branch, base: branch };
158
211
  }
159
212
  const base = binding?.base || repo.defaultBranch || "main";
@@ -164,26 +217,27 @@ export function materializeBinding({ kaiHome, repo, binding, sessionId, title, b
164
217
  // Resume: the worktree from the previous turn is the session state.
165
218
  return { cwd: dir, mode, branch, base, resumed: true };
166
219
  }
220
+ await onPrepare?.({ repo: repo.name, base });
167
221
  mkdirSync(dirname(dir), { recursive: true });
168
- const fetched = fetch(repo.primaryPath, base, { repo: repo.name, gitEnv });
169
- git(repo.primaryPath, ["worktree", "add", "-b", branch, dir, `origin/${base}`]);
222
+ const fetched = await fetch(repo.primaryPath, base, { repo: repo.name, gitEnv });
223
+ await gitAsync(repo.primaryPath, ["worktree", "add", "-b", branch, dir, `origin/${base}`]);
170
224
  // A fresh worktree has no node_modules; clone the primary checkout's
171
225
  // when the lockfiles match so the agent's tests and the preview boot
172
226
  // don't each start with a 2-minute install (see deps.mjs).
173
- const deps = seedNodeModules({ primaryPath: repo.primaryPath, cwd: dir });
227
+ const deps = await seedNodeModules({ primaryPath: repo.primaryPath, cwd: dir });
174
228
  if (binding?.carryUncommitted) {
175
229
  // Tracked changes as a patch; untracked (non-ignored) files copied.
176
- const patch = git(repo.primaryPath, ["diff", "HEAD"], { maxBuffer: 64 * 1024 * 1024 });
230
+ const patch = await gitAsync(repo.primaryPath, ["diff", "HEAD"]);
177
231
  if (patch) {
178
232
  const patchPath = join(dir, ".kai-carry.patch");
179
233
  writeFileSync(patchPath, patch + "\n");
180
234
  try {
181
- git(dir, ["apply", "--3way", patchPath]);
235
+ await gitAsync(dir, ["apply", "--3way", patchPath]);
182
236
  } finally {
183
237
  rmSync(patchPath, { force: true });
184
238
  }
185
239
  }
186
- const untracked = git(repo.primaryPath, ["ls-files", "--others", "--exclude-standard"]).split("\n").filter(Boolean);
240
+ const untracked = (await gitAsync(repo.primaryPath, ["ls-files", "--others", "--exclude-standard"])).split("\n").filter(Boolean);
187
241
  for (const rel of untracked) {
188
242
  try {
189
243
  mkdirSync(dirname(join(dir, rel)), { recursive: true });