@gleapai/kai-bridge 0.10.2 → 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.
package/src/tui.mjs ADDED
@@ -0,0 +1,76 @@
1
+ // Terminal drawing for the wizard and status screens: ANSI colours that
2
+ // switch off on pipes / NO_COLOR, and box-drawn panels laid out side by
3
+ // side when the terminal is wide enough. No dependency on purpose.
4
+
5
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
6
+
7
+ export function colorsEnabled({ stream = process.stdout, env = process.env } = {}) {
8
+ if (env.NO_COLOR) return false;
9
+ if (env.FORCE_COLOR) return true;
10
+ return !!stream.isTTY && env.TERM !== "dumb";
11
+ }
12
+
13
+ /** `palette().green("ok")` — every style is an identity function when colours are off. */
14
+ export function palette(on = colorsEnabled()) {
15
+ const wrap = (open, close) => (s) => (on ? `\x1b[${open}m${s}\x1b[${close}m` : String(s));
16
+ return {
17
+ on,
18
+ bold: wrap(1, 22),
19
+ dim: wrap(2, 22),
20
+ red: wrap(31, 39),
21
+ green: wrap(32, 39),
22
+ yellow: wrap(33, 39),
23
+ blue: wrap(34, 39),
24
+ magenta: wrap(35, 39),
25
+ cyan: wrap(36, 39),
26
+ };
27
+ }
28
+
29
+ export const strip = (s) => String(s).replace(ANSI_RE, "");
30
+ /** Printed width of a string — styling stripped. Box glyphs count as one cell. */
31
+ export const width = (s) => [...strip(s)].length;
32
+ export const padRight = (s, n) => s + " ".repeat(Math.max(0, n - width(s)));
33
+
34
+ /**
35
+ * Rounded banner: title left, `right` flush right.
36
+ * ╭──────────────────────╮
37
+ * │ TITLE v1 ● ok │
38
+ * ╰──────────────────────╯
39
+ */
40
+ export function banner({ left, right = "", cols = 64, paint = palette() }) {
41
+ const inner = Math.max(cols - 2, width(left) + width(right) + 6);
42
+ const gap = inner - 2 - width(left) - width(right) - 2;
43
+ const line = `│ ${left}${" ".repeat(Math.max(1, gap))}${right} │`;
44
+ return [paint.magenta(`╭${"─".repeat(inner)}╮`), paint.magenta("│") + line.slice(1, -1) + paint.magenta("│"), paint.magenta(`╰${"─".repeat(inner)}╯`)];
45
+ }
46
+
47
+ /**
48
+ * One box with a title in its top edge. `inner` widens it beyond its content.
49
+ * ┌ title ───────┐
50
+ * │ line │
51
+ * └──────────────┘
52
+ */
53
+ export function panel(title, lines, { inner = 0, paint = palette() } = {}) {
54
+ const w = Math.max(inner, width(title) + 3, ...lines.map((l) => width(l) + 2));
55
+ const top = paint.dim(`┌ ${title} ${"─".repeat(Math.max(0, w - width(title) - 2))}┐`);
56
+ const body = lines.map((l) => `${paint.dim("│")} ${padRight(l, w - 2)} ${paint.dim("│")}`);
57
+ return [top, ...body, paint.dim(`└${"─".repeat(w)}┘`)];
58
+ }
59
+
60
+ /** Blocks (arrays of lines) next to each other when they fit in `cols`, else stacked. */
61
+ export function layout(blocks, { cols = 80, gap = 1 } = {}) {
62
+ const widths = blocks.map((b) => Math.max(...b.map(width)));
63
+ const total = widths.reduce((a, b) => a + b, 0) + gap * (blocks.length - 1);
64
+ if (total > cols) return blocks.flatMap((b, i) => (i < blocks.length - 1 ? [...b, ""] : b));
65
+ const rows = Math.max(...blocks.map((b) => b.length));
66
+ const lines = [];
67
+ for (let r = 0; r < rows; r += 1) {
68
+ lines.push(blocks.map((b, i) => padRight(b[r] ?? "", widths[i])).join(" ".repeat(gap)).replace(/\s+$/, ""));
69
+ }
70
+ return lines;
71
+ }
72
+
73
+ /** The terminal width to draw for — capped so wide monitors keep a readable block. */
74
+ export function terminalColumns({ stream = process.stdout, max = 72, min = 40 } = {}) {
75
+ return Math.max(min, Math.min(max, stream.columns || 80));
76
+ }
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 });