@gleapai/kai-bridge 0.2.0 → 0.2.2
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/package.json +1 -1
- package/src/daemon.mjs +55 -31
- package/src/preview.mjs +42 -7
- package/src/workspace.mjs +39 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleapai/kai-bridge",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Run Gleap Kai Code sessions on your own machine with your own Claude Code / Codex login — and preview your real dev servers from the dashboard or the phone.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/daemon.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { KAI_HOME, defaultConfig, loadConfig, saveConfig } from "./config.mjs";
|
|
|
20
20
|
import { runTurn } from "./executor.mjs";
|
|
21
21
|
import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
|
|
22
22
|
import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
|
|
23
|
-
import { collectChanges, commitAndPush, copyPrimaryEnvFiles, materializeBinding, sessionSlug, worktreePath } from "./workspace.mjs";
|
|
23
|
+
import { collectChanges, commitAndPush, copyPrimaryEnvFiles, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
|
|
24
24
|
import { ServiceRunner, detectDevConfig, previewMcpServer, readDevConfig } from "./preview.mjs";
|
|
25
25
|
import { describeHarnesses, installHarness } from "./harnesses.mjs";
|
|
26
26
|
import { dirname } from "node:path";
|
|
@@ -444,6 +444,12 @@ export class BridgeDaemon {
|
|
|
444
444
|
return this.previewStart(data);
|
|
445
445
|
case "bridge.preview.stop":
|
|
446
446
|
return this.previewStop(data);
|
|
447
|
+
case "bridge.preview.keepalive":
|
|
448
|
+
// Dashboard heartbeat while someone is actually LOOKING at the
|
|
449
|
+
// preview — makes the idle auto-stop mean real idleness instead
|
|
450
|
+
// of a fixed TTL since boot.
|
|
451
|
+
if (this.services.has(data.sessionId)) this.armPreviewIdleTimer(data.sessionId);
|
|
452
|
+
return;
|
|
447
453
|
default:
|
|
448
454
|
this.log("warn", "command.unknown", { name });
|
|
449
455
|
}
|
|
@@ -488,7 +494,15 @@ export class BridgeDaemon {
|
|
|
488
494
|
const copied = copyPrimaryEnvFiles(group.primary.path, cwd);
|
|
489
495
|
if (copied.length) this.log("info", "preview.env.copied", { cwd, copied });
|
|
490
496
|
}
|
|
491
|
-
|
|
497
|
+
// Config resolution order: this worktree's own config, then the
|
|
498
|
+
// PRIMARY checkout's (a repo verified by the setup agent has its
|
|
499
|
+
// dev.yaml on an unmerged config PR — session worktrees cut from
|
|
500
|
+
// the base branch don't carry it yet), then the package.json
|
|
501
|
+
// heuristic. Closes the verified-before-config-merge window.
|
|
502
|
+
const config =
|
|
503
|
+
readDevConfig(cwd) ??
|
|
504
|
+
(cwd !== group.primary.path ? readDevConfig(group.primary.path) : null) ??
|
|
505
|
+
detectDevConfig(cwd);
|
|
492
506
|
if (!config) continue;
|
|
493
507
|
if (config.error) {
|
|
494
508
|
await report({ status: "error", error: config.error });
|
|
@@ -594,8 +608,8 @@ export class BridgeDaemon {
|
|
|
594
608
|
/**
|
|
595
609
|
* Previews are for looking at, not for hosting: stop everything after
|
|
596
610
|
* 30 idle minutes (Lukas 08-26 — was 4h). Re-armed on every (re)start,
|
|
597
|
-
* cleared on manual stop and session close.
|
|
598
|
-
*
|
|
611
|
+
* cleared on manual stop and session close. Turns never boot services
|
|
612
|
+
* themselves — they only reuse this runner while it is alive.
|
|
599
613
|
*/
|
|
600
614
|
armPreviewIdleTimer(sessionId) {
|
|
601
615
|
this.previewIdleTimers ??= new Map();
|
|
@@ -628,7 +642,7 @@ export class BridgeDaemon {
|
|
|
628
642
|
await this.api.sessionPreview(sessionId, { status: "stopped" }).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
629
643
|
}
|
|
630
644
|
|
|
631
|
-
/** One ServiceRunner per session
|
|
645
|
+
/** One ServiceRunner per session — created only by the on-demand preview; turns reuse it. */
|
|
632
646
|
runnerFor(sessionId, onStatus) {
|
|
633
647
|
let runner = this.services.get(sessionId);
|
|
634
648
|
if (!runner) {
|
|
@@ -678,11 +692,12 @@ export class BridgeDaemon {
|
|
|
678
692
|
const workDir = bound[0]?.cwd;
|
|
679
693
|
if (!workDir) throw new Error("Turn has no repositories.");
|
|
680
694
|
const repoNote = bound.length > 1 ? `\n\nRepositories for this task:\n${bound.map((b) => `- ${b.key}: ${b.cwd}`).join("\n")}` : "";
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
|
|
685
|
-
const
|
|
695
|
+
// Previews are manual-only (dashboard "Start preview") — a turn
|
|
696
|
+
// never boots dev servers on its own. When the user already has a
|
|
697
|
+
// preview running for this session, describe it to the agent and
|
|
698
|
+
// hand it the Playwright MCP so it can verify in a real browser.
|
|
699
|
+
const { note: previewNote, hasLivePreview } = await this.describeLivePreview(turn, bound, batcher);
|
|
700
|
+
const mcpServers = hasLivePreview ? [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)] : turn.mcpServers;
|
|
686
701
|
const res = await runTurn({
|
|
687
702
|
turn: { ...turn, task: `${turn.task}${repoNote}${previewNote}`, mcpServers },
|
|
688
703
|
profile,
|
|
@@ -695,12 +710,17 @@ export class BridgeDaemon {
|
|
|
695
710
|
await batcher.flush();
|
|
696
711
|
const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
|
|
697
712
|
const changes = bound.map((b) => {
|
|
713
|
+
ensureCommitExcludes(b.cwd, { allowDevConfig: !!turn.allowDevConfig });
|
|
698
714
|
const diff = collectChanges(b.cwd);
|
|
699
715
|
// Build turns in worktree mode publish the session branch so the
|
|
700
716
|
// Server can open the PR; plan turns and local mode never push.
|
|
701
717
|
const shouldPush = completed && b.mode === "worktree" && !turn.planMode && diff.files.length > 0;
|
|
702
718
|
const push = shouldPush
|
|
703
|
-
? commitAndPush(b.cwd, {
|
|
719
|
+
? commitAndPush(b.cwd, {
|
|
720
|
+
branch: b.branch,
|
|
721
|
+
allowDevConfig: !!turn.allowDevConfig,
|
|
722
|
+
message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}`,
|
|
723
|
+
})
|
|
704
724
|
: null;
|
|
705
725
|
return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, ...diff, push };
|
|
706
726
|
});
|
|
@@ -738,17 +758,22 @@ export class BridgeDaemon {
|
|
|
738
758
|
}
|
|
739
759
|
}
|
|
740
760
|
|
|
741
|
-
/**
|
|
742
|
-
|
|
761
|
+
/**
|
|
762
|
+
* Turn-path preview policy: previews start MANUALLY only. A turn never
|
|
763
|
+
* boots dev servers — that surprised people ("Kai always starts a
|
|
764
|
+
* preview") and tied every turn to a heavyweight app boot. When a
|
|
765
|
+
* dashboard-started preview is already RUNNING for this session, reuse
|
|
766
|
+
* it: describe the live services to the agent (plus browser tools via
|
|
767
|
+
* the caller). Returns { note, hasLivePreview }.
|
|
768
|
+
*/
|
|
769
|
+
async describeLivePreview(turn, bound, batcher) {
|
|
743
770
|
const notes = [];
|
|
744
771
|
const previews = [];
|
|
772
|
+
// Only a runner created by the dashboard's "Start preview" counts —
|
|
773
|
+
// the map is cleared on manual stop, idle stop, and session close.
|
|
774
|
+
const live = this.services.get(turn.sessionId);
|
|
745
775
|
for (const b of bound) {
|
|
746
|
-
// Turn path starts ONLY committed .gleap/dev.yaml services — the
|
|
747
|
-
// package.json heuristic is reserved for the user-initiated
|
|
748
|
-
// "Start preview" (auto-booting every node repo's dev server on
|
|
749
|
-
// every turn would be a heavyweight surprise).
|
|
750
776
|
const committed = readDevConfig(b.cwd);
|
|
751
|
-
const config = committed;
|
|
752
777
|
if (!committed && !turn.planMode) {
|
|
753
778
|
// Nudge the agent to make the setup durable once it has learned it.
|
|
754
779
|
notes.push(
|
|
@@ -757,32 +782,31 @@ export class BridgeDaemon {
|
|
|
757
782
|
`so Gleap can run live previews for this repo in future sessions.`,
|
|
758
783
|
);
|
|
759
784
|
}
|
|
760
|
-
if (!
|
|
761
|
-
if (
|
|
762
|
-
batcher.push({ type: "text", message: `⚠️ ${
|
|
785
|
+
if (!committed || !live) continue;
|
|
786
|
+
if (committed.error) {
|
|
787
|
+
batcher.push({ type: "text", message: `⚠️ ${committed.error}` });
|
|
763
788
|
continue;
|
|
764
789
|
}
|
|
765
|
-
const runner = this.runnerFor(turn.sessionId, (message) =>
|
|
766
|
-
batcher.push({ type: "tool_status", message, toolName: "DevServer", toolSummary: message, toolStatus: "completed", toolPartId: `svc-${Date.now()}` }),
|
|
767
|
-
);
|
|
768
790
|
try {
|
|
769
|
-
|
|
770
|
-
|
|
791
|
+
// start() on the live runner reuses already-running processes and
|
|
792
|
+
// their ports — for a booted preview this only describes it.
|
|
793
|
+
const started = await live.start(b.cwd, committed, { mode: b.mode });
|
|
794
|
+
notes.push(live.describeForAgent(started));
|
|
771
795
|
if (started.preview) previews.push({ repo: b.key, ...started.preview });
|
|
772
796
|
} catch (err) {
|
|
773
|
-
this.log("error", "
|
|
774
|
-
batcher.push({ type: "text", message: `⚠️ Could not
|
|
797
|
+
this.log("error", "preview.reuse.failed", { repo: b.key, error: err.message });
|
|
798
|
+
batcher.push({ type: "text", message: `⚠️ Could not reuse the running preview for ${b.key}: ${err.message}` });
|
|
775
799
|
}
|
|
776
800
|
}
|
|
777
801
|
if (previews.length > 0) {
|
|
778
|
-
//
|
|
779
|
-
// fallback for servers that predate it.
|
|
802
|
+
// Refresh the session-keyed report (URLs may have changed) with the
|
|
803
|
+
// turn-keyed route as fallback for servers that predate it.
|
|
780
804
|
await this.api
|
|
781
805
|
.sessionPreview(turn.sessionId, { status: "running", previews })
|
|
782
806
|
.catch(() => this.api.turnPreview(turn.turnId, { previews }))
|
|
783
807
|
.catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
784
808
|
}
|
|
785
|
-
return notes.join("");
|
|
809
|
+
return { note: notes.join(""), hasLivePreview: previews.length > 0 };
|
|
786
810
|
}
|
|
787
811
|
|
|
788
812
|
async cloneRepo({ commandId, remote, name }) {
|
package/src/preview.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { spawn } from "node:child_process";
|
|
|
24
24
|
import { createServer, connect } from "node:net";
|
|
25
25
|
import { existsSync, mkdirSync, openSync, readFileSync } from "node:fs";
|
|
26
26
|
import { networkInterfaces } from "node:os";
|
|
27
|
-
import { join, resolve } from "node:path";
|
|
27
|
+
import { join, resolve, basename } from "node:path";
|
|
28
28
|
import YAML from "yaml";
|
|
29
29
|
|
|
30
30
|
export const DEV_CONFIG_PATHS = [".gleap/dev.yaml", ".gleap/dev.yml"];
|
|
@@ -45,7 +45,14 @@ export function readDevConfig(repoRoot) {
|
|
|
45
45
|
|
|
46
46
|
export function normalizeDevConfig(raw) {
|
|
47
47
|
const services = {};
|
|
48
|
-
|
|
48
|
+
// Accept both shapes: `services: {api: {...}}` and the list form
|
|
49
|
+
// `services: [- name: api ...]` people naturally write in YAML —
|
|
50
|
+
// Object.entries on an array yields index keys, which used to name
|
|
51
|
+
// services "0", "1".
|
|
52
|
+
const rawServices = Array.isArray(raw?.services)
|
|
53
|
+
? Object.fromEntries(raw.services.filter((s) => s && typeof s === "object").map((s, i) => [String(s.name || i), s]))
|
|
54
|
+
: raw?.services || {};
|
|
55
|
+
for (const [name, s] of Object.entries(rawServices)) {
|
|
49
56
|
if (!s || typeof s !== "object" || !s.run) continue;
|
|
50
57
|
services[name] = {
|
|
51
58
|
name,
|
|
@@ -94,13 +101,21 @@ export function detectDevConfig(repoRoot) {
|
|
|
94
101
|
: existsSync(join(repoRoot, "yarn.lock"))
|
|
95
102
|
? "yarn"
|
|
96
103
|
: "npm";
|
|
104
|
+
// Name the service after the package (else the repo folder):
|
|
105
|
+
// heuristic configs from two repos in one session used to both be
|
|
106
|
+
// called "app" and collide in the shared per-session port map — the
|
|
107
|
+
// second repo silently reused the first one's port and never booted.
|
|
108
|
+
// package.json name beats the folder because WORKTREE folders are
|
|
109
|
+
// named after the session slug — identical for every repo in it.
|
|
110
|
+
const slug = (v) => String(v || "").toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
111
|
+
const name = slug(pkg.name) || slug(basename(repoRoot)) || "app";
|
|
97
112
|
return normalizeDevConfig({
|
|
98
113
|
services: {
|
|
99
114
|
// No declared port: ServiceRunner assigns a free one and exports
|
|
100
115
|
// PORT; readiness falls back to the port-listen probe.
|
|
101
|
-
|
|
116
|
+
[name]: { cwd: ".", run: `${pm} run ${script}`, readyTimeoutMs: 90_000 },
|
|
102
117
|
},
|
|
103
|
-
preview:
|
|
118
|
+
preview: name,
|
|
104
119
|
});
|
|
105
120
|
}
|
|
106
121
|
|
|
@@ -193,8 +208,28 @@ export class ServiceRunner {
|
|
|
193
208
|
async start(repoRoot, config, { mode = "worktree" } = {}) {
|
|
194
209
|
mkdirSync(this.logDir, { recursive: true });
|
|
195
210
|
const out = [];
|
|
211
|
+
// Same service name from a DIFFERENT repo is a collision, not a
|
|
212
|
+
// reuse: silently sharing the port map entry made the second repo
|
|
213
|
+
// ride the first one's port and never boot. Uniquify with the repo
|
|
214
|
+
// folder as prefix and say so — committed configs should pick
|
|
215
|
+
// collision-safe names, but the runtime must not break when they
|
|
216
|
+
// don't.
|
|
217
|
+
this.serviceRoots ??= new Map();
|
|
218
|
+
const services = Object.values(config.services).map((svc) => {
|
|
219
|
+
const owner = this.serviceRoots.get(svc.name);
|
|
220
|
+
if (owner && owner !== repoRoot) {
|
|
221
|
+
const unique = `${basename(repoRoot).toLowerCase().replace(/[^a-z0-9_-]+/g, "-")}-${svc.name}`;
|
|
222
|
+
this.onStatus(`Service name "${svc.name}" is already used by another repo in this session — running this one as "${unique}".`);
|
|
223
|
+
svc = { ...svc, name: unique };
|
|
224
|
+
}
|
|
225
|
+
this.serviceRoots.set(svc.name, repoRoot);
|
|
226
|
+
return svc;
|
|
227
|
+
});
|
|
228
|
+
const previewName = config.preview && !services.some((s) => s.name === config.preview)
|
|
229
|
+
? services.find((s) => s.name.endsWith(`-${config.preview}`))?.name ?? config.preview
|
|
230
|
+
: config.preview;
|
|
196
231
|
// Assign ports first so cross-references resolve.
|
|
197
|
-
for (const svc of
|
|
232
|
+
for (const svc of services) {
|
|
198
233
|
if (this.ports[svc.name]) continue;
|
|
199
234
|
const declared = svc.port;
|
|
200
235
|
if (mode === "local" && declared && (await isPortListening(declared))) {
|
|
@@ -206,7 +241,7 @@ export class ServiceRunner {
|
|
|
206
241
|
this.ports[svc.name] = await getFreePort();
|
|
207
242
|
}
|
|
208
243
|
}
|
|
209
|
-
for (const svc of
|
|
244
|
+
for (const svc of services) {
|
|
210
245
|
const port = this.ports[svc.name];
|
|
211
246
|
const logPath = join(this.logDir, `${svc.name}.log`);
|
|
212
247
|
if (this.adopted.has(svc.name)) {
|
|
@@ -254,7 +289,7 @@ export class ServiceRunner {
|
|
|
254
289
|
this.onStatus(ready ? `${svc.name} ready on http://localhost:${port}` : `${svc.name} did not become ready within ${Math.round(svc.readyTimeoutMs / 1000)}s — see ${logPath}`);
|
|
255
290
|
out.push({ name: svc.name, port, url: `http://localhost:${port}`, logPath, adopted: false, ready });
|
|
256
291
|
}
|
|
257
|
-
const previewSvc =
|
|
292
|
+
const previewSvc = previewName ? out.find((s) => s.name === previewName) : null;
|
|
258
293
|
const lan = lanAddress();
|
|
259
294
|
return {
|
|
260
295
|
services: out,
|
package/src/workspace.mjs
CHANGED
|
@@ -145,19 +145,47 @@ export function removeWorktree({ kaiHome, repo, sessionId, title }) {
|
|
|
145
145
|
* A failed push is NOT fatal: the dashboard still shows the diff, and the
|
|
146
146
|
* result carries the reason (no credentials, protected branch, …).
|
|
147
147
|
*/
|
|
148
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Git-level guard rails for what a turn may publish. Agent state dirs
|
|
150
|
+
* never enter commits; the preview config (.gleap/dev.yaml) stays out
|
|
151
|
+
* of FEATURE commits too — agents write it to self-verify with a dev
|
|
152
|
+
* server mid-turn, and it kept riding along into unrelated PRs (08-26
|
|
153
|
+
* release E2E). Preview-setup turns pass allowDevConfig: there the
|
|
154
|
+
* file IS the deliverable. On-demand previews read it uncommitted, so
|
|
155
|
+
* excluding it breaks nothing. Run before BOTH collectChanges and the
|
|
156
|
+
* commit so the reported diff matches what actually ships.
|
|
157
|
+
*/
|
|
158
|
+
export function ensureCommitExcludes(cwd, { allowDevConfig = false } = {}) {
|
|
159
|
+
const always = [".codex-kai/", ".kai-acp-state/", ".kai-carry.patch"];
|
|
160
|
+
const devConfig = [".gleap/dev.yaml", ".gleap/dev.yml"];
|
|
161
|
+
try {
|
|
162
|
+
// Worktrees have a FILE at .git — resolve the real exclude path via
|
|
163
|
+
// git itself. (The old join(cwd, ".git", …) silently failed in
|
|
164
|
+
// worktrees, which is how dev.yaml slipped into feature commits in
|
|
165
|
+
// the first place.) info/exclude is SHARED across a repo's
|
|
166
|
+
// worktrees, so the dev-config lines are managed both ways: added
|
|
167
|
+
// for feature turns, removed again for allowDevConfig turns.
|
|
168
|
+
const excludePath = git(cwd, ["rev-parse", "--git-path", "info/exclude"]);
|
|
169
|
+
const absolute = excludePath.startsWith("/") ? excludePath : join(cwd, excludePath);
|
|
170
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
171
|
+
const current = existsSync(absolute) ? readFileSync(absolute, "utf8") : "";
|
|
172
|
+
const kept = current
|
|
173
|
+
.split("\n")
|
|
174
|
+
.filter((line) => !devConfig.includes(line.trim()));
|
|
175
|
+
const wanted = [...always, ...(allowDevConfig ? [] : devConfig)];
|
|
176
|
+
const merged = [...kept.filter((l, i) => l.trim() !== "" || i < kept.length - 1)];
|
|
177
|
+
for (const e of wanted) if (!merged.some((l) => l.trim() === e)) merged.push(e);
|
|
178
|
+
const next = merged.join("\n").trimEnd() + "\n";
|
|
179
|
+
if (next !== current) writeFileSync(absolute, next);
|
|
180
|
+
} catch {
|
|
181
|
+
/* best-effort — a failed exclude write must never block the turn */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function commitAndPush(cwd, { branch, message, allowEmpty = false, allowDevConfig = false } = {}) {
|
|
149
186
|
const out = { committed: false, pushed: false, branch, commitSha: null, remote: null, error: null };
|
|
150
187
|
try {
|
|
151
|
-
|
|
152
|
-
const excludes = [".codex-kai/", ".kai-acp-state/", ".kai-carry.patch"];
|
|
153
|
-
const excludePath = join(cwd, ".git", "info", "exclude");
|
|
154
|
-
try {
|
|
155
|
-
const current = existsSync(excludePath) ? readFileSync(excludePath, "utf8") : "";
|
|
156
|
-
const missing = excludes.filter((e) => !current.includes(e));
|
|
157
|
-
if (missing.length) writeFileSync(excludePath, `${current.trimEnd()}\n${missing.join("\n")}\n`);
|
|
158
|
-
} catch {
|
|
159
|
-
/* worktree .git is a file → exclude lives in the main repo; fine */
|
|
160
|
-
}
|
|
188
|
+
ensureCommitExcludes(cwd, { allowDevConfig });
|
|
161
189
|
git(cwd, ["add", "-A"]);
|
|
162
190
|
const staged = git(cwd, ["diff", "--cached", "--name-only"]);
|
|
163
191
|
if (staged || allowEmpty) {
|