@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/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
+ }