@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/README.md +19 -4
- package/bin/kai-bridge.mjs +116 -60
- package/npm-shrinkwrap.json +2 -14
- package/package.json +4 -2
- package/src/api.mjs +10 -0
- package/src/config.mjs +1 -0
- package/src/daemon.mjs +118 -7
- package/src/deps.mjs +26 -5
- package/src/help.mjs +115 -0
- package/src/logs.mjs +62 -0
- package/src/ps.mjs +1 -1
- package/src/service.mjs +1 -1
- package/src/setup.mjs +412 -129
- package/src/tui.mjs +76 -0
- package/src/workspace.mjs +75 -21
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
|
|
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.
|
|
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,
|
|
76
|
-
*
|
|
77
|
-
* whose `code` the Server
|
|
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 =
|
|
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"],
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
235
|
+
await gitAsync(dir, ["apply", "--3way", patchPath]);
|
|
182
236
|
} finally {
|
|
183
237
|
rmSync(patchPath, { force: true });
|
|
184
238
|
}
|
|
185
239
|
}
|
|
186
|
-
const untracked =
|
|
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 });
|