@gleapai/kai-bridge 0.2.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 +45 -0
- package/bin/kai-bridge.mjs +275 -0
- package/package.json +47 -0
- package/runner/acp-runner.mjs +671 -0
- package/runner/lib/acp/harnesses.mjs +342 -0
- package/runner/lib/acp/mapper.mjs +575 -0
- package/runner/lib/acp/transcripts.mjs +238 -0
- package/runner/lib/contract.mjs +1122 -0
- package/runner/lib/wire-proxy.mjs +200 -0
- package/runner/personas/claude/kai-asker.md +69 -0
- package/runner/personas/claude/kai-doc-explorer.md +130 -0
- package/runner/personas/claude/kai-documentarian.md +205 -0
- package/runner/personas/claude/kai-researcher.md +68 -0
- package/runner/personas/claude/kai-resolution-analyst.md +164 -0
- package/runner/personas/codex/kai-asker.md +68 -0
- package/runner/personas/codex/kai-doc-explorer.md +130 -0
- package/runner/personas/codex/kai-documentarian.md +211 -0
- package/runner/personas/codex/kai-researcher.md +67 -0
- package/runner/personas/codex/kai-resolution-analyst.md +164 -0
- package/runner/tools/ask-user-mcp.mjs +130 -0
- package/runner/tools/todo-mcp.mjs +116 -0
- package/scripts/postinstall.mjs +24 -0
- package/src/api.mjs +141 -0
- package/src/config.mjs +76 -0
- package/src/daemon.mjs +904 -0
- package/src/executor.mjs +156 -0
- package/src/harnesses.mjs +252 -0
- package/src/preview.mjs +337 -0
- package/src/profiles.mjs +250 -0
- package/src/repos.mjs +182 -0
- package/src/service.mjs +162 -0
- package/src/setup.mjs +261 -0
- package/src/workspace.mjs +175 -0
package/src/daemon.mjs
ADDED
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
// The always-on part: one outbound realtime subscription for commands,
|
|
2
|
+
// REST for everything the device reports. Idles at ~0 CPU; reconnects
|
|
3
|
+
// with backoff; keeps the machine awake only while a turn runs.
|
|
4
|
+
//
|
|
5
|
+
// Commands arrive on `private-bridge-<deviceId>` (Sockudo / Pusher
|
|
6
|
+
// protocol, the same channel family the dashboard uses):
|
|
7
|
+
// bridge.turn.start { commandId, turnId, sessionId, profileId, repos:[{key, mode, base, carryUncommitted}], ...AgentRunOpts }
|
|
8
|
+
// bridge.turn.cancel { turnId }
|
|
9
|
+
// bridge.repo.clone { commandId, remote, name }
|
|
10
|
+
// bridge.profile.login{ commandId, profileId }
|
|
11
|
+
// bridge.rescan {}
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join, resolve as resolvePath } from "node:path";
|
|
16
|
+
import { homedir, platform } from "node:os";
|
|
17
|
+
|
|
18
|
+
import { BridgeApi, createEventBatcher } from "./api.mjs";
|
|
19
|
+
import { KAI_HOME, defaultConfig, loadConfig, saveConfig } from "./config.mjs";
|
|
20
|
+
import { runTurn } from "./executor.mjs";
|
|
21
|
+
import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
|
|
22
|
+
import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
|
|
23
|
+
import { collectChanges, commitAndPush, copyPrimaryEnvFiles, materializeBinding, sessionSlug, worktreePath } from "./workspace.mjs";
|
|
24
|
+
import { ServiceRunner, detectDevConfig, previewMcpServer, readDevConfig } from "./preview.mjs";
|
|
25
|
+
import { describeHarnesses, installHarness } from "./harnesses.mjs";
|
|
26
|
+
import { dirname } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
|
|
29
|
+
const RUNNER_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "runner");
|
|
30
|
+
|
|
31
|
+
/** Lock paths held by daemons in THIS process (cross-process uses the file). */
|
|
32
|
+
const HELD_LOCKS = new Set();
|
|
33
|
+
const REALTIME_RETRY_MS = 15_000;
|
|
34
|
+
const HEARTBEAT_MS = 30_000;
|
|
35
|
+
const USAGE_REFRESH_MS = 10 * 60_000;
|
|
36
|
+
const VERSION = "0.1.0";
|
|
37
|
+
|
|
38
|
+
export function createLogger(kaiHome = KAI_HOME) {
|
|
39
|
+
mkdirSync(join(kaiHome, "logs"), { recursive: true });
|
|
40
|
+
const file = join(kaiHome, "logs", "bridge.log");
|
|
41
|
+
return (level, msg, data) => {
|
|
42
|
+
const line = `${new Date().toISOString()} ${level} ${msg}${data ? " " + JSON.stringify(data) : ""}`;
|
|
43
|
+
try {
|
|
44
|
+
appendFileSync(file, line + "\n");
|
|
45
|
+
} catch {
|
|
46
|
+
/* best-effort */
|
|
47
|
+
}
|
|
48
|
+
if (process.env.KAI_BRIDGE_FOREGROUND === "1" || level === "error") process.stderr.write(line + "\n");
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve profiles from config; ambient/managed dirs derived here.
|
|
54
|
+
*
|
|
55
|
+
* The three ambient profiles (one per harness) are always present, even
|
|
56
|
+
* if the stored config predates a harness or lost entries: a missing
|
|
57
|
+
* ambient entry would silently remove that harness from the dashboard
|
|
58
|
+
* picker with nothing to click to get it back.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveProfiles(config, kaiHome = KAI_HOME) {
|
|
61
|
+
const stored = config.profiles || [];
|
|
62
|
+
const missing = defaultConfig().profiles.filter(
|
|
63
|
+
(d) => !stored.some((p) => p.id === d.id || (p.kind !== "managed" && p.harness === d.harness)),
|
|
64
|
+
);
|
|
65
|
+
return [...stored, ...missing].map((p) => ({
|
|
66
|
+
...p,
|
|
67
|
+
configDir: p.kind === "managed" ? managedConfigDir(p.harness, p.id, kaiHome) : ambientConfigDir(p.harness),
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Keep the machine awake for the duration of a turn (macOS caffeinate). */
|
|
72
|
+
function keepAwake() {
|
|
73
|
+
if (platform() !== "darwin") return () => {};
|
|
74
|
+
try {
|
|
75
|
+
// `-w <pid>`: caffeinate exits when THIS process does, however it
|
|
76
|
+
// dies. Without it a crash or `kill -9` orphans an assertion that
|
|
77
|
+
// keeps the machine awake — and draining — all night.
|
|
78
|
+
const p = spawn("caffeinate", ["-dimsu", "-w", String(process.pid)], { stdio: "ignore" });
|
|
79
|
+
// spawn reports ENOENT asynchronously; unhandled, it would take the
|
|
80
|
+
// daemon down in the middle of a turn.
|
|
81
|
+
p.on("error", () => {});
|
|
82
|
+
return () => {
|
|
83
|
+
try {
|
|
84
|
+
p.kill();
|
|
85
|
+
} catch {
|
|
86
|
+
/* gone */
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
} catch {
|
|
90
|
+
return () => {};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class BridgeDaemon {
|
|
95
|
+
constructor({ config = loadConfig(), kaiHome = KAI_HOME, log = createLogger(kaiHome), realtimeFactory } = {}) {
|
|
96
|
+
this.config = config;
|
|
97
|
+
this.kaiHome = kaiHome;
|
|
98
|
+
this.log = log;
|
|
99
|
+
this.api = new BridgeApi({ apiBase: config.apiBase, token: config.device?.token });
|
|
100
|
+
this.realtimeFactory = realtimeFactory;
|
|
101
|
+
this.running = new Map(); // turnId → AbortController
|
|
102
|
+
this.services = new Map(); // sessionId → ServiceRunner (lives across turns)
|
|
103
|
+
this.repoGroups = [];
|
|
104
|
+
this.usageByProfile = new Map(); // profileId → plan-usage snapshot (claude only)
|
|
105
|
+
this.stopped = false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async scanRepos() {
|
|
109
|
+
const roots = [...defaultRoots(), ...(this.config.roots || [])];
|
|
110
|
+
const checkouts = scanRoots(roots);
|
|
111
|
+
this.repoGroups = groupByRepo(checkouts, this.config.primaryOverrides || {});
|
|
112
|
+
this.log("info", "repos.scanned", { roots: roots.length, repos: this.repoGroups.length });
|
|
113
|
+
return this.repoGroups;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async hello() {
|
|
117
|
+
const profiles = await describeProfiles(resolveProfiles(this.config, this.kaiHome), this.usageByProfile);
|
|
118
|
+
const repos = toDeviceRepoReport(this.repoGroups);
|
|
119
|
+
return this.api.hello({
|
|
120
|
+
name: this.config.device?.name,
|
|
121
|
+
platform: platform(),
|
|
122
|
+
version: VERSION,
|
|
123
|
+
harnesses: describeHarnesses(this.kaiHome).map(({ binary, ...h }) => h),
|
|
124
|
+
profiles,
|
|
125
|
+
repos,
|
|
126
|
+
roots: [...defaultRoots(), ...(this.config.roots || [])],
|
|
127
|
+
repoModes: this.config.repoModes || {},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async start() {
|
|
132
|
+
if (!this.config.device?.token) throw new Error("Not paired — run `kai-bridge login` first.");
|
|
133
|
+
// Exactly one daemon per machine. Two would both receive every
|
|
134
|
+
// command on the shared channel and run two agents in the SAME
|
|
135
|
+
// worktree — interleaved events, racing pushes, mangled diffs. Very
|
|
136
|
+
// easy to hit: `kai-bridge install` and then `kai-bridge start`.
|
|
137
|
+
this.acquireLock();
|
|
138
|
+
// A previous run that was killed (reboot, crash, `kill -9`) never got
|
|
139
|
+
// to report its turns. Tell the server before doing anything else,
|
|
140
|
+
// so those sessions settle instead of spinning.
|
|
141
|
+
await this.reportInterruptedTurns();
|
|
142
|
+
await this.scanRepos();
|
|
143
|
+
// The first hello must not kill the daemon: the server may be
|
|
144
|
+
// restarting (deploys, local nodemon) — retry with backoff instead
|
|
145
|
+
// of exiting, exactly like every later heartbeat survives a gap.
|
|
146
|
+
for (let delay = 5_000; ; delay = Math.min(delay * 2, 60_000)) {
|
|
147
|
+
try {
|
|
148
|
+
await this.hello();
|
|
149
|
+
break;
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (err?.status === 401 || err?.status === 403) {
|
|
152
|
+
// Permanent: retrying a revoked token forever, silently, is
|
|
153
|
+
// how a device ends up "connected" and dead for days.
|
|
154
|
+
this.onApiError("hello", err);
|
|
155
|
+
this.releaseLock();
|
|
156
|
+
const revoked = new Error("This machine is no longer connected to Gleap — run `kai-bridge login`.");
|
|
157
|
+
// Lets the CLI exit 0 under the service: launchd must treat a
|
|
158
|
+
// revoked pairing as a deliberate stop, not a crash to retry.
|
|
159
|
+
revoked.code = "REVOKED";
|
|
160
|
+
throw revoked;
|
|
161
|
+
}
|
|
162
|
+
this.log("warn", "hello.retry", { error: err?.message, nextInMs: delay });
|
|
163
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
this.connectRealtime();
|
|
167
|
+
// REF'd on purpose: this timer is what keeps the process alive. With
|
|
168
|
+
// it unref'd, a realtime socket that gave up for good left no handle
|
|
169
|
+
// behind, node exited 0, and launchd's SuccessfulExit:false meant the
|
|
170
|
+
// machine stayed offline until the next login — silently.
|
|
171
|
+
this.heartbeat = setInterval(() => {
|
|
172
|
+
this.api
|
|
173
|
+
.heartbeat({ running: [...this.running.keys()] })
|
|
174
|
+
.catch((err) => this.onApiError("heartbeat", err));
|
|
175
|
+
}, HEARTBEAT_MS);
|
|
176
|
+
// Plan-usage windows for the composer popover. Fire-and-forget on a
|
|
177
|
+
// slow cadence, unref'd (the heartbeat keeps the process alive), and
|
|
178
|
+
// NEVER in hello's path — the probe spawns the CLI (~2s per profile).
|
|
179
|
+
void this.refreshUsageLimits();
|
|
180
|
+
this.usageTimer = setInterval(() => void this.refreshUsageLimits(), USAGE_REFRESH_MS);
|
|
181
|
+
this.usageTimer.unref?.();
|
|
182
|
+
this.log("info", "started", { device: this.config.device.id });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Probe each claude profile's plan-usage windows and, when anything
|
|
187
|
+
* changed, push the fresh profile list via hello (idempotent $set on
|
|
188
|
+
* the server). Skipped entirely while a turn is running — the probe
|
|
189
|
+
* competes for the same login.
|
|
190
|
+
*/
|
|
191
|
+
async refreshUsageLimits() {
|
|
192
|
+
if (this.stopped || this.running.size > 0) return;
|
|
193
|
+
let changed = false;
|
|
194
|
+
for (const p of resolveProfiles(this.config, this.kaiHome)) {
|
|
195
|
+
if (p.harness !== "claude") continue;
|
|
196
|
+
try {
|
|
197
|
+
const prev = this.usageByProfile.get(p.id) ?? null;
|
|
198
|
+
const usage = (await probeUsageLimits(p.harness, p.configDir, this.kaiHome)) ?? null;
|
|
199
|
+
// `fetchedAt` alone must not count as a change or every probe
|
|
200
|
+
// would trigger a hello.
|
|
201
|
+
const comparable = (v) => (v ? JSON.stringify({ ...v, fetchedAt: null }) : "null");
|
|
202
|
+
if (comparable(usage) !== comparable(prev)) changed = true;
|
|
203
|
+
if (usage) this.usageByProfile.set(p.id, usage);
|
|
204
|
+
else this.usageByProfile.delete(p.id);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
this.log("warn", "usage.probe.failed", { profile: p.id, error: err?.message });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (changed && !this.stopped) {
|
|
210
|
+
await this.hello().catch((err) => this.log("warn", "usage.hello.failed", { error: err?.message }));
|
|
211
|
+
this.log("info", "usage.refreshed", { profiles: this.usageByProfile.size });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
stop() {
|
|
216
|
+
this.stopped = true;
|
|
217
|
+
clearInterval(this.heartbeat);
|
|
218
|
+
clearInterval(this.usageTimer);
|
|
219
|
+
if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
|
|
220
|
+
for (const ctrl of this.running.values()) ctrl.abort();
|
|
221
|
+
for (const runner of this.services.values()) runner.stopAll();
|
|
222
|
+
this.realtime?.disconnect?.();
|
|
223
|
+
this.releaseLock();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── single instance ────────────────────────────────────────────────
|
|
227
|
+
get lockPath() {
|
|
228
|
+
return join(this.kaiHome, "daemon.lock");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
acquireLock() {
|
|
232
|
+
mkdirSync(this.kaiHome, { recursive: true });
|
|
233
|
+
// Same-process guard: the pid check below can't see a second daemon
|
|
234
|
+
// living inside this very process (the Desktop host embeds one).
|
|
235
|
+
if (HELD_LOCKS.has(this.lockPath)) {
|
|
236
|
+
throw new Error("kai-bridge is already running on this machine (in this process).");
|
|
237
|
+
}
|
|
238
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
239
|
+
try {
|
|
240
|
+
writeFileSync(this.lockPath, String(process.pid), { flag: "wx" });
|
|
241
|
+
this.holdsLock = true;
|
|
242
|
+
HELD_LOCKS.add(this.lockPath);
|
|
243
|
+
process.on("exit", () => this.releaseLock());
|
|
244
|
+
return;
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (err.code !== "EEXIST") throw err;
|
|
247
|
+
const pid = Number(readFileSync(this.lockPath, "utf8").trim());
|
|
248
|
+
if (pid && pid !== process.pid) {
|
|
249
|
+
try {
|
|
250
|
+
process.kill(pid, 0);
|
|
251
|
+
throw new Error(`kai-bridge is already running on this machine (pid ${pid}).`);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
if (e.code !== "ESRCH") throw e;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Stale lock from a killed process — take it.
|
|
257
|
+
rmSync(this.lockPath, { force: true });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
releaseLock() {
|
|
263
|
+
if (!this.holdsLock) return;
|
|
264
|
+
this.holdsLock = false;
|
|
265
|
+
HELD_LOCKS.delete(this.lockPath);
|
|
266
|
+
try {
|
|
267
|
+
rmSync(this.lockPath, { force: true });
|
|
268
|
+
} catch {
|
|
269
|
+
/* best effort */
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── crash recovery ─────────────────────────────────────────────────
|
|
274
|
+
get inflightPath() {
|
|
275
|
+
return join(this.kaiHome, "state", "inflight.json");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
readInflight() {
|
|
279
|
+
try {
|
|
280
|
+
return JSON.parse(readFileSync(this.inflightPath, "utf8"));
|
|
281
|
+
} catch {
|
|
282
|
+
return [];
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
writeInflight(ids) {
|
|
287
|
+
try {
|
|
288
|
+
mkdirSync(join(this.kaiHome, "state"), { recursive: true });
|
|
289
|
+
writeFileSync(this.inflightPath, JSON.stringify(ids));
|
|
290
|
+
} catch {
|
|
291
|
+
/* best effort */
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
rememberInflight(turnId) {
|
|
296
|
+
this.writeInflight([...new Set([...this.readInflight(), turnId])]);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
forgetInflight(turnId) {
|
|
300
|
+
this.writeInflight(this.readInflight().filter((id) => id !== turnId));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Turns this machine was running when it was killed — report them dead. */
|
|
304
|
+
async reportInterruptedTurns() {
|
|
305
|
+
const ids = this.readInflight();
|
|
306
|
+
if (!ids.length) return;
|
|
307
|
+
this.writeInflight([]);
|
|
308
|
+
for (const turnId of ids) {
|
|
309
|
+
this.log("warn", "turn.interrupted", { turnId });
|
|
310
|
+
await this.api
|
|
311
|
+
.turnResult(turnId, {
|
|
312
|
+
status: "failed",
|
|
313
|
+
error: `${this.config.device?.name || "This machine"} restarted while the turn was running — send the message again to retry.`,
|
|
314
|
+
})
|
|
315
|
+
.catch(() => {});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The realtime client retries a refused connection forever, but two
|
|
321
|
+
* states are terminal: six failed reconnects of an ESTABLISHED socket
|
|
322
|
+
* (a rolling deploy behind a load balancer looks exactly like that),
|
|
323
|
+
* and a `refused` close code. Both used to log one line and leave the
|
|
324
|
+
* device subscribed to nothing while still heartbeating — online in
|
|
325
|
+
* the dashboard, deaf in practice. Reconnect from scratch instead.
|
|
326
|
+
*/
|
|
327
|
+
handleRealtimeState(state) {
|
|
328
|
+
this.log("info", "realtime", { state });
|
|
329
|
+
const text = String(state || "");
|
|
330
|
+
if (text === "connected") {
|
|
331
|
+
this.realtimeDown = false;
|
|
332
|
+
// Anything published while we were away is gone — Pusher-style
|
|
333
|
+
// events aren't replayed. Re-announce and pull whatever the server
|
|
334
|
+
// still thinks we should be running.
|
|
335
|
+
void this.reconcileAfterReconnect();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (text === "disconnected" || text.startsWith("error") || text.startsWith("subscription_error")) {
|
|
339
|
+
this.realtimeDown = true;
|
|
340
|
+
if (this.stopped || this.realtimeRetry) return;
|
|
341
|
+
this.realtimeRetry = setTimeout(() => {
|
|
342
|
+
this.realtimeRetry = null;
|
|
343
|
+
if (this.stopped) return;
|
|
344
|
+
this.log("info", "realtime.reconnect");
|
|
345
|
+
try {
|
|
346
|
+
this.realtime?.disconnect?.();
|
|
347
|
+
} catch {
|
|
348
|
+
/* already gone */
|
|
349
|
+
}
|
|
350
|
+
this.connectRealtime();
|
|
351
|
+
}, REALTIME_RETRY_MS);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* After a gap (sleep, wifi, deploy) ask the server what it still
|
|
357
|
+
* believes we're running and pick those turns up. Without this, a
|
|
358
|
+
* session started while the lid was closed simply never ran.
|
|
359
|
+
*/
|
|
360
|
+
async reconcileAfterReconnect() {
|
|
361
|
+
try {
|
|
362
|
+
await this.hello();
|
|
363
|
+
} catch (err) {
|
|
364
|
+
this.log("warn", "reconnect.hello.failed", { error: err.message });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
const pending = await this.api.pendingTurns();
|
|
369
|
+
for (const turn of pending?.turns || []) {
|
|
370
|
+
if (this.running.has(turn.turnId)) continue;
|
|
371
|
+
this.log("info", "turn.recovered", { turnId: turn.turnId });
|
|
372
|
+
void this.startTurn(turn).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
|
|
373
|
+
}
|
|
374
|
+
} catch (err) {
|
|
375
|
+
// Older server without the endpoint: the server-side sweep still
|
|
376
|
+
// fails orphaned turns, so this is a nice-to-have, not a must.
|
|
377
|
+
this.log("debug", "pending.unavailable", { error: err.message });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** A 401/403 means this device was unpaired — retrying forever is noise. */
|
|
382
|
+
onApiError(where, err) {
|
|
383
|
+
if (err?.status === 401 || err?.status === 403) {
|
|
384
|
+
if (!this.authLost) {
|
|
385
|
+
this.authLost = true;
|
|
386
|
+
this.log("error", "auth.invalid", { where, status: err.status });
|
|
387
|
+
process.stderr.write(
|
|
388
|
+
"\nThis machine is no longer connected to Gleap (it may have been disconnected in Settings → Devices).\n" +
|
|
389
|
+
"Reconnect it with: kai-bridge login\n\n",
|
|
390
|
+
);
|
|
391
|
+
// Post-start (heartbeat exists): a revoked device can never
|
|
392
|
+
// recover on its own — every later call would 403 too, so the
|
|
393
|
+
// daemon would sit "running" and warn forever while the machine
|
|
394
|
+
// is dead in the picker. Shut down deliberately with exit 0:
|
|
395
|
+
// launchd's SuccessfulExit:false leaves an intentional stop
|
|
396
|
+
// alone (a crash-restart loop would just hammer the API with
|
|
397
|
+
// more 403s). `kai-bridge login` starting again brings it back.
|
|
398
|
+
if (this.heartbeat) {
|
|
399
|
+
this.stop();
|
|
400
|
+
setTimeout(() => process.exit(0), 500).unref?.();
|
|
401
|
+
process.exitCode = 0;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
this.log("warn", `${where}.failed`, { error: err.message });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
connectRealtime() {
|
|
410
|
+
const factory = this.realtimeFactory ?? defaultRealtimeFactory;
|
|
411
|
+
this.realtime = factory({
|
|
412
|
+
config: this.config,
|
|
413
|
+
channel: `private-bridge-${this.config.device.id}`,
|
|
414
|
+
onEvent: (name, data) => this.handleCommand(name, data).catch((err) => this.log("error", "command.failed", { name, error: err.message })),
|
|
415
|
+
onState: (state) => this.handleRealtimeState(state),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async handleCommand(name, data) {
|
|
420
|
+
this.log("info", "command", { name, turnId: data?.turnId, commandId: data?.commandId });
|
|
421
|
+
switch (name) {
|
|
422
|
+
case "bridge.turn.start":
|
|
423
|
+
return this.startTurn(data);
|
|
424
|
+
case "bridge.turn.cancel":
|
|
425
|
+
this.running.get(data.turnId)?.abort();
|
|
426
|
+
return;
|
|
427
|
+
case "bridge.rescan":
|
|
428
|
+
await this.scanRepos();
|
|
429
|
+
return this.hello();
|
|
430
|
+
case "bridge.repo.clone":
|
|
431
|
+
return this.cloneRepo(data);
|
|
432
|
+
case "bridge.repo.locate":
|
|
433
|
+
return this.locateRepo(data);
|
|
434
|
+
case "bridge.profile.login":
|
|
435
|
+
return this.profileLogin(data);
|
|
436
|
+
case "bridge.harness.install":
|
|
437
|
+
return this.harnessInstall(data);
|
|
438
|
+
case "bridge.session.close":
|
|
439
|
+
this.services.get(data.sessionId)?.stopAll();
|
|
440
|
+
this.services.delete(data.sessionId);
|
|
441
|
+
this.clearPreviewIdleTimer(data.sessionId);
|
|
442
|
+
return;
|
|
443
|
+
case "bridge.preview.start":
|
|
444
|
+
return this.previewStart(data);
|
|
445
|
+
case "bridge.preview.stop":
|
|
446
|
+
return this.previewStop(data);
|
|
447
|
+
default:
|
|
448
|
+
this.log("warn", "command.unknown", { name });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* On-demand preview (dashboard "Start preview"): boot the repos' dev
|
|
454
|
+
* services inside the session's EXISTING worktrees. Resolves paths by
|
|
455
|
+
* existence only — materializing checkouts is the turn path's job, so
|
|
456
|
+
* a pruned worktree is an error, not a re-clone.
|
|
457
|
+
*/
|
|
458
|
+
async previewStart({ sessionId, title, repos }) {
|
|
459
|
+
const report = (payload) =>
|
|
460
|
+
this.api.sessionPreview(sessionId, payload).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
461
|
+
await report({ status: "starting" });
|
|
462
|
+
try {
|
|
463
|
+
// Pass 1 — resolve every session repo (checkout, env, config) and
|
|
464
|
+
// collect the companion repos their configs ask for, before booting
|
|
465
|
+
// anything: companions must boot FIRST so `${port:x}` cross-refs
|
|
466
|
+
// resolve in the shared per-session port map.
|
|
467
|
+
const resolved = [];
|
|
468
|
+
const companionWants = new Map(); // repo key → { optional }
|
|
469
|
+
for (const r of repos || []) {
|
|
470
|
+
const group = this.repoGroups.find((g) => g.key === r.key);
|
|
471
|
+
if (!group) {
|
|
472
|
+
await report({ status: "error", error: `Repository ${r.key} is not checked out on this device.` });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
|
|
476
|
+
const cwd = mode === "local" ? group.primary.path : worktreePath(this.kaiHome, group.name, sessionSlug(sessionId, title));
|
|
477
|
+
if (!existsSync(cwd)) {
|
|
478
|
+
await report({
|
|
479
|
+
status: "error",
|
|
480
|
+
error: `The workspace for this session is gone on this device — send Kai a message to recreate it, then start the preview again.`,
|
|
481
|
+
});
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// Gitignored env files never reach a fresh worktree — copy the
|
|
485
|
+
// primary checkout's so env-dependent apps (the dashboard itself)
|
|
486
|
+
// don't boot blank. Never overwrites existing files.
|
|
487
|
+
if (mode !== "local") {
|
|
488
|
+
const copied = copyPrimaryEnvFiles(group.primary.path, cwd);
|
|
489
|
+
if (copied.length) this.log("info", "preview.env.copied", { cwd, copied });
|
|
490
|
+
}
|
|
491
|
+
const config = readDevConfig(cwd) ?? detectDevConfig(cwd);
|
|
492
|
+
if (!config) continue;
|
|
493
|
+
if (config.error) {
|
|
494
|
+
await report({ status: "error", error: config.error });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
for (const c of config.companions || []) {
|
|
498
|
+
if ((repos || []).some((sr) => sr.key === c.repo)) continue; // already part of the session
|
|
499
|
+
const prev = companionWants.get(c.repo);
|
|
500
|
+
// Required by any config → required overall.
|
|
501
|
+
companionWants.set(c.repo, { optional: (prev ? prev.optional : true) && c.optional });
|
|
502
|
+
}
|
|
503
|
+
resolved.push({ key: r.key, cwd, config, mode });
|
|
504
|
+
}
|
|
505
|
+
if (!resolved.length) {
|
|
506
|
+
await report({ status: "error", error: "No dev config found — add .gleap/dev.yaml to the repo (services + preview)." });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const runner = this.runnerFor(sessionId, (message) => this.log("info", "preview.status", { sessionId, message }));
|
|
510
|
+
// Pass 2 — companions: clone when missing, then boot from the
|
|
511
|
+
// PRIMARY checkout in local mode, so an already-running dev server
|
|
512
|
+
// on the declared port is adopted instead of duplicated.
|
|
513
|
+
const companionPreviews = [];
|
|
514
|
+
for (const [key, { optional }] of companionWants) {
|
|
515
|
+
try {
|
|
516
|
+
const group = await this.ensureCompanionCheckout(key, report);
|
|
517
|
+
const config = readDevConfig(group.primary.path) ?? detectDevConfig(group.primary.path);
|
|
518
|
+
if (!config) throw new Error(`no dev config — add .gleap/dev.yaml to ${key}`);
|
|
519
|
+
if (config.error) throw new Error(config.error);
|
|
520
|
+
const started = await this.bootRepoWithConfig(runner, group.primary.path, config, "local");
|
|
521
|
+
if (started.preview) companionPreviews.push({ repo: key, ...started.preview });
|
|
522
|
+
} catch (err) {
|
|
523
|
+
if (optional) {
|
|
524
|
+
this.log("warn", "preview.companion.skipped", { sessionId, repo: key, error: err.message });
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
runner.stopAll();
|
|
528
|
+
this.services.delete(sessionId);
|
|
529
|
+
await report({ status: "error", error: `Companion ${key}: ${err.message}` });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// Pass 3 — the session repos themselves (worktree mode boots its
|
|
534
|
+
// own copy on a free port: it must serve the changed code).
|
|
535
|
+
const previews = [];
|
|
536
|
+
for (const { key, cwd, config, mode } of resolved) {
|
|
537
|
+
try {
|
|
538
|
+
const started = await this.bootRepoWithConfig(runner, cwd, config, mode);
|
|
539
|
+
if (started.preview) previews.push({ repo: key, ...started.preview });
|
|
540
|
+
} catch (err) {
|
|
541
|
+
runner.stopAll();
|
|
542
|
+
this.services.delete(sessionId);
|
|
543
|
+
await report({ status: "error", error: err.message });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
this.armPreviewIdleTimer(sessionId);
|
|
548
|
+
await report({ status: "running", previews: [...previews, ...companionPreviews] });
|
|
549
|
+
} catch (err) {
|
|
550
|
+
this.log("error", "preview.start.failed", { sessionId, error: err.message });
|
|
551
|
+
await report({ status: "error", error: err.message });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Boot one repo's services with an already-resolved config; throws a
|
|
557
|
+
* readable error (with the failing service's log tail) when a service
|
|
558
|
+
* never becomes ready — a "running" preview must not 404.
|
|
559
|
+
*/
|
|
560
|
+
async bootRepoWithConfig(runner, cwd, config, mode) {
|
|
561
|
+
const started = await runner.start(cwd, config, { mode });
|
|
562
|
+
const dead = started.services.find((s) => !s.adopted && !s.ready);
|
|
563
|
+
if (dead) {
|
|
564
|
+
let tail = "";
|
|
565
|
+
try {
|
|
566
|
+
const raw = readFileSync(dead.logPath, "utf8");
|
|
567
|
+
tail = raw.trim().split("\n").slice(-3).join(" · ").slice(0, 300);
|
|
568
|
+
} catch {}
|
|
569
|
+
throw new Error(`${dead.name} did not start${tail ? ` — ${tail}` : ""}`);
|
|
570
|
+
}
|
|
571
|
+
return started;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* A companion repo must exist on this machine. Cloned → its group.
|
|
576
|
+
* Not cloned → clone it into the preferred root (github.com keys only
|
|
577
|
+
* — the key itself is the remote address) and rescan.
|
|
578
|
+
*/
|
|
579
|
+
async ensureCompanionCheckout(key, report) {
|
|
580
|
+
let group = this.repoGroups.find((g) => g.key === key);
|
|
581
|
+
if (group) return group;
|
|
582
|
+
const [host, owner, name] = String(key).split("/");
|
|
583
|
+
if (host !== "github.com" || !owner || !name) {
|
|
584
|
+
throw new Error(`not cloned on this device — clone it, then start the preview again`);
|
|
585
|
+
}
|
|
586
|
+
await report({ status: "starting", error: null, note: `Cloning ${name}…` });
|
|
587
|
+
this.log("info", "preview.companion.clone", { key });
|
|
588
|
+
await this.cloneRepo({ remote: `https://github.com/${owner}/${name}.git`, name });
|
|
589
|
+
group = this.repoGroups.find((g) => g.key === key);
|
|
590
|
+
if (!group) throw new Error(`clone finished but the repository was not discovered — check the daemon log`);
|
|
591
|
+
return group;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Previews are for looking at, not for hosting: stop everything after
|
|
596
|
+
* 30 idle minutes (Lukas 08-26 — was 4h). Re-armed on every (re)start,
|
|
597
|
+
* cleared on manual stop and session close. Turn-path services are
|
|
598
|
+
* unaffected until a preview start adopts the same runner.
|
|
599
|
+
*/
|
|
600
|
+
armPreviewIdleTimer(sessionId) {
|
|
601
|
+
this.previewIdleTimers ??= new Map();
|
|
602
|
+
this.clearPreviewIdleTimer(sessionId);
|
|
603
|
+
const t = setTimeout(() => {
|
|
604
|
+
this.previewIdleTimers.delete(sessionId);
|
|
605
|
+
const runner = this.services.get(sessionId);
|
|
606
|
+
if (!runner) return;
|
|
607
|
+
runner.stopAll();
|
|
608
|
+
this.services.delete(sessionId);
|
|
609
|
+
this.log("info", "preview.idle.stopped", { sessionId });
|
|
610
|
+
this.api
|
|
611
|
+
.sessionPreview(sessionId, { status: "stopped", error: "Stopped automatically after 30 minutes." })
|
|
612
|
+
.catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
613
|
+
}, this.previewIdleMs ?? 30 * 60 * 1000);
|
|
614
|
+
t.unref?.();
|
|
615
|
+
this.previewIdleTimers.set(sessionId, t);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
clearPreviewIdleTimer(sessionId) {
|
|
619
|
+
const t = this.previewIdleTimers?.get(sessionId);
|
|
620
|
+
if (t) clearTimeout(t);
|
|
621
|
+
this.previewIdleTimers?.delete(sessionId);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async previewStop({ sessionId }) {
|
|
625
|
+
this.services.get(sessionId)?.stopAll();
|
|
626
|
+
this.services.delete(sessionId);
|
|
627
|
+
this.clearPreviewIdleTimer(sessionId);
|
|
628
|
+
await this.api.sessionPreview(sessionId, { status: "stopped" }).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** One ServiceRunner per session, shared by the turn path and on-demand preview. */
|
|
632
|
+
runnerFor(sessionId, onStatus) {
|
|
633
|
+
let runner = this.services.get(sessionId);
|
|
634
|
+
if (!runner) {
|
|
635
|
+
runner = new ServiceRunner({ kaiHome: this.kaiHome, sessionId, log: this.log, onStatus });
|
|
636
|
+
this.services.set(sessionId, runner);
|
|
637
|
+
}
|
|
638
|
+
return runner;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Map the Server's repo bindings onto local checkouts; throw a readable error when one is missing. */
|
|
642
|
+
bindRepos(turn) {
|
|
643
|
+
const bound = [];
|
|
644
|
+
for (const r of turn.repos || []) {
|
|
645
|
+
const group = this.repoGroups.find((g) => g.key === r.key);
|
|
646
|
+
if (!group) throw new Error(`Repository ${r.key} is not checked out on this device.`);
|
|
647
|
+
const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
|
|
648
|
+
const ws = materializeBinding({
|
|
649
|
+
kaiHome: this.kaiHome,
|
|
650
|
+
repo: { name: group.name, primaryPath: group.primary.path, defaultBranch: group.primary.defaultBranch },
|
|
651
|
+
binding: { mode, base: r.base, carryUncommitted: r.carryUncommitted },
|
|
652
|
+
sessionId: turn.sessionId,
|
|
653
|
+
title: turn.title,
|
|
654
|
+
});
|
|
655
|
+
bound.push({ key: r.key, ...ws });
|
|
656
|
+
// Remember the choice per repo (the UI asks once, then sticks).
|
|
657
|
+
this.config.repoModes = { ...(this.config.repoModes || {}), [r.key]: mode };
|
|
658
|
+
}
|
|
659
|
+
saveConfig(this.config, this.kaiHome);
|
|
660
|
+
return bound;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async startTurn(turn) {
|
|
664
|
+
const { turnId } = turn;
|
|
665
|
+
if (this.running.has(turnId)) return;
|
|
666
|
+
const ctrl = new AbortController();
|
|
667
|
+
this.running.set(turnId, ctrl);
|
|
668
|
+
const releaseAwake = keepAwake();
|
|
669
|
+
let outcome = null;
|
|
670
|
+
this.rememberInflight(turnId);
|
|
671
|
+
const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
|
|
672
|
+
try {
|
|
673
|
+
const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === turn.profileId) ?? { id: "gleap-key", kind: "gleap-key", harness: turn.harness };
|
|
674
|
+
const bound = this.bindRepos(turn);
|
|
675
|
+
// Multi-repo: the runner's cwd is the first repo; the others are
|
|
676
|
+
// reachable as siblings under the same worktree root or by their
|
|
677
|
+
// local paths — the prompt lists them.
|
|
678
|
+
const workDir = bound[0]?.cwd;
|
|
679
|
+
if (!workDir) throw new Error("Turn has no repositories.");
|
|
680
|
+
const repoNote = bound.length > 1 ? `\n\nRepositories for this task:\n${bound.map((b) => `- ${b.key}: ${b.cwd}`).join("\n")}` : "";
|
|
681
|
+
// Preview tier A: start the repos' dev services (from .gleap/dev.yaml)
|
|
682
|
+
// once per session, tell the agent where they run, and hand it the
|
|
683
|
+
// Playwright MCP so it can verify in a real browser.
|
|
684
|
+
const previewNote = await this.startServices(turn, bound, batcher);
|
|
685
|
+
const mcpServers = previewNote ? [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)] : turn.mcpServers;
|
|
686
|
+
const res = await runTurn({
|
|
687
|
+
turn: { ...turn, task: `${turn.task}${repoNote}${previewNote}`, mcpServers },
|
|
688
|
+
profile,
|
|
689
|
+
workDir,
|
|
690
|
+
kaiHome: this.kaiHome,
|
|
691
|
+
signal: ctrl.signal,
|
|
692
|
+
onEvent: (ev) => batcher.push(ev),
|
|
693
|
+
onLog: (l) => this.log("debug", "runner", { line: l.slice(0, 500) }),
|
|
694
|
+
});
|
|
695
|
+
await batcher.flush();
|
|
696
|
+
const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
|
|
697
|
+
const changes = bound.map((b) => {
|
|
698
|
+
const diff = collectChanges(b.cwd);
|
|
699
|
+
// Build turns in worktree mode publish the session branch so the
|
|
700
|
+
// Server can open the PR; plan turns and local mode never push.
|
|
701
|
+
const shouldPush = completed && b.mode === "worktree" && !turn.planMode && diff.files.length > 0;
|
|
702
|
+
const push = shouldPush
|
|
703
|
+
? commitAndPush(b.cwd, { branch: b.branch, message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}` })
|
|
704
|
+
: null;
|
|
705
|
+
return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, ...diff, push };
|
|
706
|
+
});
|
|
707
|
+
// Built OUTSIDE the report call: if posting the result throws, the
|
|
708
|
+
// catch below must not turn a finished turn into a failed one. The
|
|
709
|
+
// work is already committed and pushed at this point.
|
|
710
|
+
outcome = {
|
|
711
|
+
status: ctrl.signal.aborted ? "cancelled" : res.rateLimited ? "rate_limited" : res.code === 0 ? "completed" : "failed",
|
|
712
|
+
exitCode: res.code,
|
|
713
|
+
result: res.result,
|
|
714
|
+
// The runner's own failure text (e.g. the engine's usage-limit
|
|
715
|
+
// message) — the Server prefers this over its generic fallback.
|
|
716
|
+
...(res.lastError && res.code !== 0 ? { error: res.lastError.replace(/^acp-runner: /, "").slice(0, 500) } : {}),
|
|
717
|
+
changes,
|
|
718
|
+
profileId: profile.id,
|
|
719
|
+
};
|
|
720
|
+
} catch (err) {
|
|
721
|
+
this.log("error", "turn.failed", { turnId, error: err.message });
|
|
722
|
+
await batcher.flush().catch(() => {});
|
|
723
|
+
outcome = outcome ?? { status: "failed", error: err.message };
|
|
724
|
+
} finally {
|
|
725
|
+
// One report, retried until it lands — a dropped result is what
|
|
726
|
+
// leaves a session spinning forever in the dashboard.
|
|
727
|
+
if (outcome) {
|
|
728
|
+
await this.api
|
|
729
|
+
.turnResult(turnId, outcome, {
|
|
730
|
+
onRetry: (err, attempt, delay) =>
|
|
731
|
+
this.log("warn", "result.retry", { turnId, attempt, nextInMs: delay, error: err.message }),
|
|
732
|
+
})
|
|
733
|
+
.catch((err) => this.log("error", "result.lost", { turnId, error: err.message }));
|
|
734
|
+
}
|
|
735
|
+
this.forgetInflight(turnId);
|
|
736
|
+
releaseAwake();
|
|
737
|
+
this.running.delete(turnId);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Start/adopt dev services for the turn's repos; returns the prompt note ("" when no dev.yaml). */
|
|
742
|
+
async startServices(turn, bound, batcher) {
|
|
743
|
+
const notes = [];
|
|
744
|
+
const previews = [];
|
|
745
|
+
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
|
+
const committed = readDevConfig(b.cwd);
|
|
751
|
+
const config = committed;
|
|
752
|
+
if (!committed && !turn.planMode) {
|
|
753
|
+
// Nudge the agent to make the setup durable once it has learned it.
|
|
754
|
+
notes.push(
|
|
755
|
+
`\n\nNo .gleap/dev.yaml found in ${b.key}. If you figure out how this project's dev server runs, ` +
|
|
756
|
+
`write .gleap/dev.yaml (services: { <name>: { cwd, run, port, health } }, preview: <name>) ` +
|
|
757
|
+
`so Gleap can run live previews for this repo in future sessions.`,
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
if (!config) continue;
|
|
761
|
+
if (config.error) {
|
|
762
|
+
batcher.push({ type: "text", message: `⚠️ ${config.error}` });
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
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
|
+
try {
|
|
769
|
+
const started = await runner.start(b.cwd, config, { mode: b.mode });
|
|
770
|
+
notes.push(runner.describeForAgent(started));
|
|
771
|
+
if (started.preview) previews.push({ repo: b.key, ...started.preview });
|
|
772
|
+
} catch (err) {
|
|
773
|
+
this.log("error", "services.start.failed", { repo: b.key, error: err.message });
|
|
774
|
+
batcher.push({ type: "text", message: `⚠️ Could not start dev services for ${b.key}: ${err.message}` });
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (previews.length > 0) {
|
|
778
|
+
// Session-keyed report (new servers) with the turn-keyed route as
|
|
779
|
+
// fallback for servers that predate it.
|
|
780
|
+
await this.api
|
|
781
|
+
.sessionPreview(turn.sessionId, { status: "running", previews })
|
|
782
|
+
.catch(() => this.api.turnPreview(turn.turnId, { previews }))
|
|
783
|
+
.catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
784
|
+
}
|
|
785
|
+
return notes.join("");
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async cloneRepo({ commandId, remote, name }) {
|
|
789
|
+
// Prefer the directory the machine's repos already live in (e.g.
|
|
790
|
+
// ~/Documents/Gleap), not the bare scan root that discovered them
|
|
791
|
+
// (~/Documents) — "Clone here" should land next to the other checkouts.
|
|
792
|
+
const root = preferredCloneRoot(this.repoGroups, (this.config.roots || [])[0] ?? defaultRoots()[0]);
|
|
793
|
+
if (!root) throw new Error("No scan root to clone into — add one with `kai-bridge repo roots add <dir>`.");
|
|
794
|
+
const target = join(root, name);
|
|
795
|
+
await new Promise((resolve, reject) => {
|
|
796
|
+
const p = spawn("git", ["clone", remote, target], { stdio: "ignore" });
|
|
797
|
+
p.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`git clone exited ${code}`))));
|
|
798
|
+
p.on("error", reject);
|
|
799
|
+
});
|
|
800
|
+
await this.scanRepos();
|
|
801
|
+
await this.hello();
|
|
802
|
+
if (commandId) await this.api.commandAck(commandId, { ok: true, path: target });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Manual escape from auto-discovery: point a repo at a checkout the
|
|
807
|
+
* scan never found (cloned outside the scan roots, a second working
|
|
808
|
+
* copy, a remote whose URL doesn't match the connected one).
|
|
809
|
+
*
|
|
810
|
+
* The folder's PARENT becomes a scan root too, so everything else
|
|
811
|
+
* living beside it is discovered from now on — the common case is a
|
|
812
|
+
* whole workspace directory the defaults didn't cover. Passing only a
|
|
813
|
+
* path (no repoKey) is exactly that: "also look in here".
|
|
814
|
+
*/
|
|
815
|
+
async locateRepo({ commandId, repoKey, path: rawPath }) {
|
|
816
|
+
try {
|
|
817
|
+
const target = resolvePath(String(rawPath || "").replace(/^~(?=$|\/)/, homedir()));
|
|
818
|
+
if (!existsSync(target)) throw new Error(`${target} does not exist on this machine.`);
|
|
819
|
+
if (repoKey && !existsSync(join(target, ".git"))) {
|
|
820
|
+
throw new Error(`${target} is not a git checkout.`);
|
|
821
|
+
}
|
|
822
|
+
const root = repoKey ? dirname(target) : target;
|
|
823
|
+
this.config.roots = [...new Set([...(this.config.roots || []), root])];
|
|
824
|
+
if (repoKey) {
|
|
825
|
+
this.config.primaryOverrides = { ...(this.config.primaryOverrides || {}), [repoKey]: target };
|
|
826
|
+
}
|
|
827
|
+
saveConfig(this.config, this.kaiHome);
|
|
828
|
+
await this.scanRepos();
|
|
829
|
+
await this.hello();
|
|
830
|
+
// Report what the checkout's remote actually resolves to: a
|
|
831
|
+
// mismatch is why the repo was missing in the first place, and the
|
|
832
|
+
// dashboard can say so instead of silently doing nothing.
|
|
833
|
+
const found = repoKey ? this.repoGroups.find((g) => g.key === repoKey) : null;
|
|
834
|
+
if (commandId) {
|
|
835
|
+
await this.api.commandAck(commandId, {
|
|
836
|
+
ok: !repoKey || !!found,
|
|
837
|
+
path: target,
|
|
838
|
+
root,
|
|
839
|
+
...(repoKey && !found
|
|
840
|
+
? { error: `${target} is a checkout of a different repository than ${repoKey}.` }
|
|
841
|
+
: {}),
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
} catch (err) {
|
|
845
|
+
this.log("error", "repo.locate.failed", { error: err.message });
|
|
846
|
+
if (commandId) await this.api.commandAck(commandId, { ok: false, error: err.message }).catch(() => {});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async harnessInstall({ commandId, harness }) {
|
|
851
|
+
const lines = [];
|
|
852
|
+
try {
|
|
853
|
+
const res = await installHarness(harness, { kaiHome: this.kaiHome, onLog: (l) => { lines.push(l); this.log("info", "harness.install", { harness, line: l }); } });
|
|
854
|
+
await this.hello();
|
|
855
|
+
if (commandId) await this.api.commandAck(commandId, { ok: res.ok, version: res.version, log: lines });
|
|
856
|
+
} catch (err) {
|
|
857
|
+
this.log("error", "harness.install.failed", { harness, error: err.message });
|
|
858
|
+
if (commandId) await this.api.commandAck(commandId, { ok: false, error: err.message, log: lines }).catch(() => {});
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async profileLogin({ commandId, profileId }) {
|
|
863
|
+
const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === profileId);
|
|
864
|
+
if (!profile) throw new Error(`Unknown profile ${profileId}`);
|
|
865
|
+
if (profile.kind === "managed") createManagedProfile(profile.harness, profile.id, this.kaiHome);
|
|
866
|
+
// Logins are interactive: open a terminal on this machine running the
|
|
867
|
+
// harness's own login under the profile's config dir, and re-announce
|
|
868
|
+
// the profile state once it exits.
|
|
869
|
+
const child = openLoginTerminal(profile.harness, profile.configDir);
|
|
870
|
+
if (!child) throw new Error(`Could not open a terminal for ${profile.harness} login on this device.`);
|
|
871
|
+
child.unref?.();
|
|
872
|
+
if (commandId) await this.api.commandAck(commandId, { ok: true, opened: "terminal" });
|
|
873
|
+
const poll = setInterval(() => this.hello().catch(() => {}), 15_000);
|
|
874
|
+
poll.unref?.();
|
|
875
|
+
setTimeout(() => clearInterval(poll), 10 * 60_000).unref?.();
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** Default realtime transport: @sockudo/client against the Gleap realtime host. */
|
|
880
|
+
export function defaultRealtimeFactory({ config, channel, onEvent, onState }) {
|
|
881
|
+
let client = null;
|
|
882
|
+
(async () => {
|
|
883
|
+
const { default: Sockudo } = await import("@sockudo/client");
|
|
884
|
+
client = new Sockudo(config.realtime.appKey, {
|
|
885
|
+
wsHost: config.realtime.wsHost,
|
|
886
|
+
wssPort: 443,
|
|
887
|
+
forceTLS: true,
|
|
888
|
+
enabledTransports: ["ws", "wss"],
|
|
889
|
+
protocolVersion: 2,
|
|
890
|
+
connectionRecovery: true,
|
|
891
|
+
channelAuthorization: {
|
|
892
|
+
endpoint: `${config.apiBase}/gleapcode/bridge/devices/me/pusher`,
|
|
893
|
+
transport: "ajax",
|
|
894
|
+
headers: { Authorization: `Bearer ${config.device.token}` },
|
|
895
|
+
},
|
|
896
|
+
});
|
|
897
|
+
client.connection?.bind?.("state_change", (s) => onState(s.current));
|
|
898
|
+
const ch = client.subscribe(channel);
|
|
899
|
+
ch.bind_global?.((name, data) => {
|
|
900
|
+
if (typeof name === "string" && name.startsWith("bridge.")) onEvent(name, data);
|
|
901
|
+
});
|
|
902
|
+
})().catch((err) => onState(`error:${err.message}`));
|
|
903
|
+
return { disconnect: () => client?.disconnect?.() };
|
|
904
|
+
}
|