@gleapai/kai-bridge 0.9.1 → 0.10.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 CHANGED
@@ -12,48 +12,34 @@ import { cloneRepository, locateRepository } from './repository-setup.mjs';
12
12
  // bridge.repo.clone { commandId, remote, name }
13
13
  // bridge.profile.login{ commandId, profileId }
14
14
  // bridge.rescan {}
15
- // bridge.verify.login.start { commandId, requestId, sessionId, repoKey, previewNeeded } — headed sign-in window (see preview-login.mjs)
16
- // bridge.verify.login.done { commandId, requestId } — "Mark done": export now
17
- // bridge.verify.login.cancel { commandId, requestId }
18
- // bridge.verify.evidence.retry { commandId?, turnId } — re-upload a kept evidence dir → PUT …/verification/artifacts
19
15
  // bridge.session.close { sessionId } — stops the preview, aborts the session's running turns
16
+ // bridge.preview.publish { sessionId, sessionUrl, title, repos, companionRemotes } — re-boot the preview with public
17
+ // addresses (hostnames + tunnel credentials come from POST /devices/me/public-hosts)
18
+ // bridge.preview.unpublish { sessionId } — stop sharing: back to local addresses, the preview keeps running
20
19
 
21
20
  import { spawn } from "node:child_process";
22
21
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
- import { execFile } from "node:child_process";
24
- import { join, resolve as resolvePath } from "node:path";
25
- import { homedir, platform } from "node:os";
22
+ import { join } from "node:path";
23
+ import { platform } from "node:os";
26
24
 
27
25
  import { BridgeApi, createEventBatcher } from "./api.mjs";
28
26
  import { ensureClaudeAcpPatched } from "./acp-patch.mjs";
27
+ import { ensurePlaywrightTimeoutPatched } from "./playwright-patch.mjs";
29
28
  import { KAI_HOME, defaultConfig, loadConfig, saveConfig } from "./config.mjs";
29
+ import { WARMUP_HEADER } from "./gateway.mjs";
30
+
31
+ /** Pre-warm navigation cap: a cold dev server of a big app needs minutes, not the probe's seconds. */
32
+ const PREWARM_TIMEOUT_MS = 15 * 60_000;
30
33
  import { runTurn } from "./executor.mjs";
31
34
  import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
32
35
  import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
33
36
  import { describeBranchChanges, discardChanges, collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, currentHead, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
34
- import { ServiceRunner, detectDevConfig, effectiveReload, ensurePreviewBrowser, isPortListening, previewMcpServer, readDevConfig } from "./preview.mjs";
35
- import { ARTIFACT_MAX_AGE_MS, buildVerificationPayload, createStageHeartbeat, deriveApiAuthHeader, redactJsonlFile, sweepArtifacts, sweepOldArtifacts, unionArtifacts, writeSecretsFile } from "./verify.mjs";
36
- import { PreviewError, envRedactionValues, previewErrorPayload, toPreviewErrorPayload } from "./preview-errors.mjs";
37
+ import { ServiceRunner, detectDevConfig, ensurePreviewBrowser, launchOptionsFor, loadPlaywright, preferredStablePort, previewMcpServer, readDevConfig } from "./preview.mjs";
38
+ import { PreviewError, previewErrorPayload, toPreviewErrorPayload } from "./preview-errors.mjs";
37
39
  import { buildCloneCommand, collectCompanions, prefersSsh, resolveCompanionRemote } from "./companions.mjs";
40
+ import { TunnelManager } from "./tunnel.mjs";
41
+ import { resolveCloudflared } from "./tunnel-binary.mjs";
38
42
  import { establishedConnections } from "./ports.mjs";
39
- import {
40
- buildOriginMap,
41
- captureLogin,
42
- displayAvailable,
43
- filterStorageState,
44
- invertOriginMap,
45
- launchOptionsFor,
46
- loadPlaywright,
47
- mergeFinalState,
48
- normalizeOrigin,
49
- parseStorageStateOutput,
50
- preferredStablePort,
51
- probeLogin,
52
- redactDeep,
53
- redactionSet,
54
- rewriteStorageState,
55
- storageStateIsEmpty,
56
- } from "./preview-login.mjs";
57
43
  import { GIT_AUTH_TTL_MS, gitAuthEnv, isGitAuthError } from "./git-auth.mjs";
58
44
  import { describeHarnesses, installHarness, probeHarnessAuth } from "./harnesses.mjs";
59
45
  import { probeHarnessModels } from "./models.mjs";
@@ -61,28 +47,16 @@ import { decideRestart, decideUpdate, fetchLatestVersion, installVersion, instal
61
47
  import { dirname } from "node:path";
62
48
  import { fileURLToPath } from "node:url";
63
49
 
64
- const PROBE_TIMEOUT_COLD_BOOT_MS = 60_000;
65
50
  const CLONE_TIMEOUT_MS = 3 * 60_000;
66
51
  const COMPANION_MAX_DEPTH = 3;
67
52
  const RUNNER_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "runner");
68
53
 
69
- /** A captured landing URL moved onto the current origin map (null when its origin is not running). */
70
- function rewriteUrl(url, originMap) {
71
- const origin = normalizeOrigin(url);
72
- const to = origin && originMap ? originMap.get(origin) : null;
73
- if (!to) return null;
74
- try {
75
- const u = new URL(String(url));
76
- return `${to}${u.pathname}${u.search}${u.hash}`;
77
- } catch {
78
- return null;
79
- }
80
- }
81
-
82
54
  /** Lock paths held by daemons in THIS process (cross-process uses the file). */
83
55
  const HELD_LOCKS = new Set();
84
56
  const REALTIME_RETRY_MS = 15_000;
85
57
  const HEARTBEAT_MS = 30_000;
58
+ /** Safety net under the realtime channel: ask the server for work it thinks we run (see pullPendingWork). */
59
+ const PENDING_POLL_MS = 60_000;
86
60
  const USAGE_REFRESH_MS = 10 * 60_000;
87
61
  // Harness model catalogues change on releases, not by the minute.
88
62
  const MODELS_REFRESH_MS = 6 * 60 * 60_000;
@@ -171,9 +145,6 @@ export class BridgeDaemon {
171
145
  this.updateAvailable = null; // newer registry version, when one exists
172
146
  this.updateError = null; // why the last self-update did not apply
173
147
  this.updating = false; // npm is replacing our files — refuse new turns
174
- this.logins = new Map(); // requestId → { sessionId, repoKey, handle } — open sign-in windows (preview-login.mjs)
175
- this.verifyLocks = new Map(); // `${userId}:${repoKey}` → promise chain (one probe/refresh of a sign-in at a time)
176
- this.previewStatusListeners = new Map(); // sessionId → (message) => void — the booting heartbeat of a verify turn
177
148
  this.unknownDevKeysLogged = new Set(); // "<repo>:<key>" — unknown dev.yaml keys are logged once
178
149
  this.restartPending = false; // files on disk are a different version than the one we loaded; restart when idle
179
150
  }
@@ -187,7 +158,7 @@ export class BridgeDaemon {
187
158
  }
188
159
 
189
160
  async hello() {
190
- const profiles = await describeProfiles(resolveProfiles(this.config, this.kaiHome), this.usageByProfile);
161
+ const profiles = carryAuthStates(await describeProfiles(resolveProfiles(this.config, this.kaiHome), this.usageByProfile), (this.lastAuthStates ??= new Map()));
191
162
  const repos = toDeviceRepoReport(this.repoGroups);
192
163
  return this.api.hello({
193
164
  name: this.config.device?.name,
@@ -201,10 +172,6 @@ export class BridgeDaemon {
201
172
  updateAvailable: this.updateAvailable || null,
202
173
  updateError: this.updateError || null,
203
174
  autoUpdate: this.config.autoUpdate !== false,
204
- // Verify sign-in: can this machine open a headed Chrome window, and
205
- // which sign-in requests are open right now (replayed after sleep).
206
- capabilities: { display: this.displayAvailable() },
207
- activeLogins: [...this.logins.keys()],
208
175
  });
209
176
  }
210
177
 
@@ -219,16 +186,29 @@ export class BridgeDaemon {
219
186
  // (postinstall applies the patch; a self-update or --ignore-scripts
220
187
  // install can leave it unpatched). Idempotent, best-effort.
221
188
  ensureClaudeAcpPatched({ log: (level, event, data) => this.log(level, event, data) });
189
+ // The browser MCP's 30 s default per tool call cannot survive a heavy
190
+ // dev build — raised in the bundled playwright-core (see playwright-patch.mjs).
191
+ ensurePlaywrightTimeoutPatched({ log: (level, event, data) => this.log(level, event, data) });
222
192
  // A previous run that was killed (reboot, crash, `kill -9`) never got
223
193
  // to report its turns. Tell the server before doing anything else,
224
194
  // so those sessions settle instead of spinning.
225
195
  await this.reportInterruptedTurns();
226
196
  // Dev servers a killed daemon left behind would hold the declared ports
227
- // (→ port_busy on the next preview) — end them; then drop evidence dirs
228
- // nobody retried for two days.
197
+ // (→ port_busy on the next preview) — end them.
198
+ // Previews from the previous daemon life (self-update, `launchctl kickstart`,
199
+ // a crash) are RE-ADOPTED when their dev servers are still up — a warm
200
+ // Vite is minutes of compile nobody wants to pay again. What cannot be
201
+ // resumed is ended like any orphan.
202
+ await this.resumePreviews();
203
+ // Public links of the resumed previews come back too; a cloudflared the
204
+ // previous daemon left behind is ended first.
205
+ await this.tunnel
206
+ .resume({
207
+ liveSessionIds: [...this.services.keys()],
208
+ resolveBinary: () => (this.resolveCloudflared ?? resolveCloudflared)({ kaiHome: this.kaiHome, device: this.config?.device?.name || "this device", log: (...a) => this.log(...a) }),
209
+ })
210
+ .catch((err) => this.log("warn", "tunnel.resume.failed", { error: err?.message }));
229
211
  this.killOrphanedServices();
230
- const swept = sweepOldArtifacts(join(this.kaiHome, "artifacts"), { maxAgeMs: ARTIFACT_MAX_AGE_MS });
231
- if (swept.length) this.log("info", "artifacts.swept", { count: swept.length });
232
212
  await this.scanRepos();
233
213
  // The first hello must not kill the daemon: the server may be
234
214
  // restarting (deploys, local nodemon) — retry with backoff instead
@@ -236,6 +216,7 @@ export class BridgeDaemon {
236
216
  for (let delay = 5_000; ; delay = Math.min(delay * 2, 60_000)) {
237
217
  try {
238
218
  await this.hello();
219
+ await this.reportResumedPreviews();
239
220
  break;
240
221
  } catch (err) {
241
222
  if (err?.status === 401 || err?.status === 403) {
@@ -263,10 +244,20 @@ export class BridgeDaemon {
263
244
  // behind, node exited 0, and launchd's SuccessfulExit:false meant the
264
245
  // machine stayed offline until the next login — silently.
265
246
  this.heartbeat = setInterval(() => {
247
+ // `previews`: the sessions with a live runner — the Server's reaper
248
+ // stops what it still believes runs here but is not in this list.
266
249
  this.api
267
- .heartbeat({ running: [...this.running.keys()] })
250
+ .heartbeat({ running: [...this.running.keys()], previews: [...this.services.keys()] })
251
+ .then(() => this.flushPendingReports())
268
252
  .catch((err) => this.onApiError("heartbeat", err));
269
253
  }, HEARTBEAT_MS);
254
+ // The realtime channel can be silently dead (a subscription that
255
+ // failed its auth during a Server restart, a socket the client
256
+ // believes is fine): every minute, pull whatever the server still
257
+ // expects this machine to run. Idempotent — a turn already running
258
+ // or already started by the channel is skipped.
259
+ this.pendingPoll = setInterval(() => void this.pullPendingWork("poll"), this.pendingPollMs ?? PENDING_POLL_MS);
260
+ this.pendingPoll.unref?.();
270
261
  // Plan-usage windows for the composer popover. Fire-and-forget on a
271
262
  // slow cadence, unref'd (the heartbeat keeps the process alive), and
272
263
  // NEVER in hello's path — the probe spawns the CLI (~2s per profile).
@@ -299,7 +290,7 @@ export class BridgeDaemon {
299
290
  // into them as soon as no turn is running — BEFORE asking the registry,
300
291
  // which needs network this check must not depend on.
301
292
  const onDisk = installedVersionOrNull();
302
- const restart = decideRestart({ loaded: VERSION, installed: onDisk, running: this.running.size + this.logins.size + this.services.size });
293
+ const restart = decideRestart({ loaded: VERSION, installed: onDisk, running: this.running.size + this.services.size });
303
294
  this.restartPending = restart.action === "defer";
304
295
  if (restart.action === "restart") {
305
296
  this.log("info", "update.restart.stale", { loaded: VERSION, installed: onDisk });
@@ -317,10 +308,9 @@ export class BridgeDaemon {
317
308
  const decision = decideUpdate({
318
309
  current: VERSION,
319
310
  latest,
320
- // An open sign-in window is work in flight too: replacing our files
321
- // under a headed Chrome the user is typing into loses the capture —
322
- // and so is a live preview someone may be looking at right now.
323
- running: this.running.size + this.logins.size + this.services.size,
311
+ // A live preview someone may be looking at right now is work in
312
+ // flight too.
313
+ running: this.running.size + this.services.size,
324
314
  autoUpdate: this.config.autoUpdate !== false,
325
315
  lastAttempt: this.config.selfUpdate?.lastAttempt ?? null,
326
316
  });
@@ -399,7 +389,11 @@ export class BridgeDaemon {
399
389
  const candidates = profiles
400
390
  .filter((p) => p.harness === harness && p.kind !== "gleap-key" && p.configDir)
401
391
  .sort((a, b) => Number(b.kind === "ambient") - Number(a.kind === "ambient"));
402
- const profile = candidates.find((p) => probeHarnessAuth(harness, p.configDir, this.kaiHome)?.state === "signed_in");
392
+ const auths = candidates.map((p) => ({ profile: p, auth: probeHarnessAuth(harness, p.configDir, this.kaiHome) }));
393
+ const profile = auths.find(({ auth }) => auth?.state === "signed_in")?.profile;
394
+ // No definite answer (the CLI timed out) is not "signed out": keep
395
+ // the previous list rather than blanking the picker.
396
+ if (!profile && auths.some(({ auth }) => auth?.state === "unknown")) continue;
403
397
  let models = null;
404
398
  if (profile) {
405
399
  try {
@@ -453,27 +447,35 @@ export class BridgeDaemon {
453
447
  /**
454
448
  * Shut down: abort turns, stop every preview (telling the dashboard why —
455
449
  * `stopped` / `daemon_restarted`, so the card offers Start instead of
456
- * showing dead links), close sign-in windows. Resolves once the preview
450
+ * showing dead links). Resolves once the preview
457
451
  * reports have been given a bounded chance to land (the self-update
458
452
  * restart awaits it; sync callers may ignore the promise).
459
453
  */
460
454
  stop() {
461
455
  this.stopped = true;
462
456
  clearInterval(this.heartbeat);
457
+ clearInterval(this.pendingPoll);
463
458
  clearInterval(this.usageTimer);
464
459
  clearInterval(this.modelsTimer);
465
460
  clearInterval(this.updateTimer);
466
461
  if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
467
462
  for (const entry of this.running.values()) entry.ctrl.abort();
463
+ // Previews OUTLIVE the daemon: gateways close (the next daemon re-binds
464
+ // the ports), the dev servers keep running and are resumed from
465
+ // state/previews.json — a warm app stays warm across self-updates and
466
+ // restarts. Ones nobody resumes are reaped by the next daemon's idle
467
+ // timer or orphan sweep.
468
468
  const reports = [];
469
469
  for (const [sessionId, runner] of this.services) {
470
- runner.stopAll();
470
+ // The handoff file must describe the runner as it is NOW (pids, ports,
471
+ // warmed) — previewStart wrote it before the warm-up finished.
472
+ const prev = this.readPreviewSnapshots()[sessionId];
473
+ if (prev) this.writePreviewSnapshot(sessionId, { ...prev, runner: runner.snapshot(), savedAt: new Date().toISOString() });
474
+ runner.detach();
471
475
  this.clearPreviewIdleTimer(sessionId);
472
- reports.push(this.api.sessionPreview(sessionId, { status: "stopped", urls: [], previews: [], errorCode: "daemon_restarted", error: "kai-bridge restarted — start the preview again." }).catch(() => {}));
473
476
  }
474
477
  this.services.clear();
475
- this.writePreviewPids([]);
476
- void this.teardownLogins(() => true, "daemon stopping");
478
+ for (const w of this.prewarming?.values() ?? []) void w.close();
477
479
  this.realtime?.disconnect?.();
478
480
  this.releaseLock();
479
481
  return Promise.race([Promise.allSettled(reports), new Promise((r) => setTimeout(r, 3_000).unref?.())]).then(() => undefined);
@@ -531,7 +533,7 @@ export class BridgeDaemon {
531
533
  return join(this.kaiHome, "state", "inflight.json");
532
534
  }
533
535
 
534
- /** `[{ turnId, sessionId?, agent?, artifactsDir? }]` — pre-0.5.0 files held bare ids. */
536
+ /** `[{ turnId, sessionId?, agent? }]` — pre-0.5.0 files held bare ids. */
535
537
  readInflight() {
536
538
  try {
537
539
  const raw = JSON.parse(readFileSync(this.inflightPath, "utf8"));
@@ -561,12 +563,7 @@ export class BridgeDaemon {
561
563
  this.writeInflight(this.readInflight().filter((e) => e.turnId !== turnId));
562
564
  }
563
565
 
564
- /**
565
- * Turns this machine was running when it was killed — report them dead.
566
- * A verify turn also gets a `blocked` report ("kai-bridge restarted
567
- * mid-run") carrying whatever evidence the browser had written, so the
568
- * card shows the partial recording instead of spinning.
569
- */
566
+ /** Turns this machine was running when it was killed — report them dead. */
570
567
  async reportInterruptedTurns() {
571
568
  const entries = this.readInflight();
572
569
  if (!entries.length) return;
@@ -574,16 +571,6 @@ export class BridgeDaemon {
574
571
  for (const entry of entries) {
575
572
  const { turnId } = entry;
576
573
  this.log("warn", "turn.interrupted", { turnId, agent: entry.agent });
577
- if (entry.agent === "kai-verifier") {
578
- const uploads = entry.artifactsDir ? await this.uploadSweptEvidence(turnId, entry.artifactsDir) : { uploads: [], failed: 0 };
579
- await this.api
580
- .turnVerification(turnId, {
581
- ...buildVerificationPayload(null, uploads.uploads, { fallbackReason: "kai-bridge restarted mid-run", evidence: { failed: uploads.failed, ...(uploads.failed && entry.artifactsDir ? { kept: entry.artifactsDir } : {}) } }),
582
- blockedCode: "other",
583
- })
584
- .catch((err) => this.log("warn", "verify.interrupted.report.failed", { turnId, error: err?.message }));
585
- if (entry.artifactsDir && uploads.failed === 0) rmSync(entry.artifactsDir, { recursive: true, force: true });
586
- }
587
574
  await this.api
588
575
  .turnResult(turnId, {
589
576
  status: "failed",
@@ -616,6 +603,127 @@ export class BridgeDaemon {
616
603
  }
617
604
  }
618
605
 
606
+ get previewSnapshotsPath() {
607
+ return join(this.kaiHome, "state", "previews.json");
608
+ }
609
+
610
+ readPreviewSnapshots() {
611
+ try {
612
+ const raw = JSON.parse(readFileSync(this.previewSnapshotsPath, "utf8"));
613
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
614
+ } catch {
615
+ return {};
616
+ }
617
+ }
618
+
619
+ writePreviewSnapshot(sessionId, data) {
620
+ try {
621
+ mkdirSync(dirname(this.previewSnapshotsPath), { recursive: true });
622
+ const all = this.readPreviewSnapshots();
623
+ if (data) all[sessionId] = data;
624
+ else delete all[sessionId];
625
+ writeFileSync(this.previewSnapshotsPath, JSON.stringify(all), { mode: 0o600 });
626
+ } catch (err) {
627
+ this.log("warn", "preview.snapshot.failed", { sessionId, error: err?.message });
628
+ }
629
+ }
630
+
631
+ /**
632
+ * Re-adopt the previews of the previous daemon life whose dev servers
633
+ * are still alive and listening (see ServiceRunner.resume). Runs BEFORE
634
+ * the orphan reaper and before the first hello — the Server is told once
635
+ * hello succeeded (`reportResumedPreviews`).
636
+ */
637
+ async resumePreviews() {
638
+ const snapshots = this.readPreviewSnapshots();
639
+ this.resumedReports = [];
640
+ for (const [sessionId, snap] of Object.entries(snapshots)) {
641
+ const runner = this.runnerFor(sessionId);
642
+ let failed;
643
+ try {
644
+ failed = await runner.resume(snap.runner);
645
+ } catch (err) {
646
+ failed = [{ error: err?.message }];
647
+ }
648
+ const previewSvcs = (snap.runner?.services ?? []).filter((svc) => !svc.adopted);
649
+ if (failed.length || !previewSvcs.length) {
650
+ this.log("info", "preview.resume.skipped", { sessionId, failed });
651
+ runner.stopAll();
652
+ this.services.delete(sessionId);
653
+ this.writePreviewSnapshot(sessionId, null);
654
+ this.resumedReports.push({ sessionId, body: { status: "stopped", urls: [], previews: [], errorCode: "daemon_restarted", error: "kai-bridge restarted — start the preview again." } });
655
+ continue;
656
+ }
657
+ if (snap.manual) (this.manualPreviews ??= new Set()).add(sessionId);
658
+ this.armPreviewIdleTimer(sessionId);
659
+ this.log("info", "preview.resumed", { sessionId, services: previewSvcs.map((svc) => `${svc.name}:${svc.port}`), warmed: !!snap.runner?.warmed });
660
+ this.resumedReports.push({ sessionId, body: { status: "running", previews: snap.previews ?? [], urls: snap.urls ?? snap.previews ?? [], landingUrl: snap.landingUrl ?? null } });
661
+ }
662
+ }
663
+
664
+ async reportResumedPreviews() {
665
+ const reports = this.resumedReports ?? [];
666
+ this.resumedReports = [];
667
+ for (const { sessionId, body } of reports) await this.reportPreview(sessionId, body);
668
+ await this.flushPendingReports();
669
+ }
670
+
671
+ /**
672
+ * One preview report. A `stopped` / `error` that cannot be delivered (the
673
+ * laptop is offline — an idle stop on 2026-09-14 left the dashboard on
674
+ * "running" for a week) is kept in state/pending-reports.json and resent
675
+ * after the next successful heartbeat. Latest per session wins; a session
676
+ * that runs again drops its stale one.
677
+ */
678
+ async reportPreview(sessionId, body) {
679
+ try {
680
+ await this.api.sessionPreview(sessionId, body);
681
+ return true;
682
+ } catch (err) {
683
+ this.log("warn", "preview.report.failed", { sessionId, error: err?.message });
684
+ if (body?.status === "stopped" || body?.status === "error") this.writePendingReport(sessionId, body);
685
+ return false;
686
+ }
687
+ }
688
+
689
+ get pendingReportsPath() {
690
+ return join(this.kaiHome, "state", "pending-reports.json");
691
+ }
692
+
693
+ readPendingReports() {
694
+ try {
695
+ const raw = JSON.parse(readFileSync(this.pendingReportsPath, "utf8"));
696
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
697
+ } catch {
698
+ return {};
699
+ }
700
+ }
701
+
702
+ writePendingReport(sessionId, body) {
703
+ try {
704
+ mkdirSync(dirname(this.pendingReportsPath), { recursive: true });
705
+ const all = this.readPendingReports();
706
+ if (body) all[sessionId] = body;
707
+ else delete all[sessionId];
708
+ writeFileSync(this.pendingReportsPath, JSON.stringify(all), { mode: 0o600 });
709
+ } catch (err) {
710
+ this.log("warn", "preview.pending.failed", { sessionId, error: err?.message });
711
+ }
712
+ }
713
+
714
+ async flushPendingReports() {
715
+ for (const [sessionId, body] of Object.entries(this.readPendingReports())) {
716
+ if (this.services.has(sessionId)) {
717
+ this.writePendingReport(sessionId, null); // running again: the old stop is history
718
+ continue;
719
+ }
720
+ if (await this.reportPreview(sessionId, body)) {
721
+ this.writePendingReport(sessionId, null);
722
+ this.log("info", "preview.report.resent", { sessionId, status: body?.status });
723
+ }
724
+ }
725
+ }
726
+
619
727
  /**
620
728
  * ServiceRunner hook: every dev-server pid is persisted while it lives —
621
729
  * with its port and the session's title/link, so `kai-bridge ps` can
@@ -654,9 +762,12 @@ export class BridgeDaemon {
654
762
  */
655
763
  killOrphanedServices({ kill = process.kill, platform = process.platform } = {}) {
656
764
  const entries = this.readPreviewPids();
657
- this.writePreviewPids([]);
765
+ const keep = new Set();
766
+ for (const runner of this.services.values()) for (const pid of runner.resumedPids?.values() ?? []) keep.add(pid);
767
+ this.writePreviewPids(entries.filter((e) => keep.has(e.pid)));
658
768
  let killed = 0;
659
769
  for (const e of entries) {
770
+ if (keep.has(e.pid)) continue;
660
771
  try {
661
772
  kill(e.pid, 0); // alive?
662
773
  } catch {
@@ -712,7 +823,7 @@ export class BridgeDaemon {
712
823
  /* already gone */
713
824
  }
714
825
  this.connectRealtime();
715
- }, REALTIME_RETRY_MS);
826
+ }, this.realtimeRetryMs ?? REALTIME_RETRY_MS);
716
827
  }
717
828
  }
718
829
 
@@ -728,6 +839,20 @@ export class BridgeDaemon {
728
839
  this.log("warn", "reconnect.hello.failed", { error: err.message });
729
840
  return;
730
841
  }
842
+ await this.pullPendingWork("reconnect");
843
+ }
844
+
845
+ /**
846
+ * Ask the server what it still believes we're running and pick those
847
+ * turns up. Called after a reconnect and on
848
+ * a slow timer (`PENDING_POLL_MS`) — the timer is what saves a device
849
+ * whose channel subscription failed silently. Never runs twice at once,
850
+ * never while an update is replacing our files (the channel refuses
851
+ * turns then too).
852
+ */
853
+ async pullPendingWork(via = "poll") {
854
+ if (this.stopped || this.updating || this.pullingPending) return;
855
+ this.pullingPending = true;
731
856
  try {
732
857
  const pending = await this.api.pendingTurns();
733
858
  // Cancels the server settled while we were away (reaper / watchdog):
@@ -737,24 +862,21 @@ export class BridgeDaemon {
737
862
  for (const turnId of cancelled) {
738
863
  const run = this.running.get(turnId);
739
864
  if (!run) continue;
740
- this.log("info", "turn.cancel.replayed", { turnId });
865
+ this.log("info", "turn.cancel.replayed", { turnId, via });
741
866
  run.ctrl.abort();
742
867
  }
743
868
  for (const turn of pending?.turns || []) {
744
869
  if (this.running.has(turn.turnId) || cancelled.has(String(turn.turnId))) continue;
745
- this.log("info", "turn.recovered", { turnId: turn.turnId });
870
+ this.log("info", "turn.recovered", { turnId: turn.turnId, via });
746
871
  void this.startTurn(turn).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
747
872
  }
748
- // Sign-in requests published while we were away: open their windows now.
749
- for (const req of pending?.loginRequests || []) {
750
- if (!req?.requestId || this.logins.has(req.requestId)) continue;
751
- this.log("info", "verify.login.recovered", { requestId: req.requestId });
752
- void this.loginStart(req).catch((err) => this.log("error", "verify.login.recover.failed", { error: err.message }));
753
- }
754
873
  } catch (err) {
755
- // Older server without the endpoint: the server-side sweep still
756
- // fails orphaned turns, so this is a nice-to-have, not a must.
757
- this.log("debug", "pending.unavailable", { error: err.message });
874
+ // Older server without the endpoint, or the server is restarting: the
875
+ // server-side sweep still fails orphaned turns, so this is a
876
+ // nice-to-have, not a must.
877
+ this.log("debug", "pending.unavailable", { via, error: err.message });
878
+ } finally {
879
+ this.pullingPending = false;
758
880
  }
759
881
  }
760
882
 
@@ -838,30 +960,33 @@ export class BridgeDaemon {
838
960
  entry.ctrl.abort();
839
961
  }
840
962
  }
841
- this.services.get(data.sessionId)?.stopAll();
842
- this.services.delete(data.sessionId);
843
- this.clearPreviewIdleTimer(data.sessionId);
844
- // A sign-in window opened for this session has nothing to sign in to any more.
845
- await this.teardownLogins((l) => l.sessionId === data.sessionId, "session closed");
963
+ // The dashboard's Stop (`reason: "stopped"`) keeps a live preview: the
964
+ // user may come back to it, and a warm dev server is minutes of
965
+ // compile. The idle reaper ends it when nobody comes back.
966
+ // Merged / deleted sessions (and pre-reason Servers) stop it now.
967
+ if (data.reason === "stopped" && this.services.has(data.sessionId)) {
968
+ this.log("info", "preview.kept.session_stopped", { sessionId: data.sessionId });
969
+ this.armPreviewIdleTimer(data.sessionId);
970
+ } else {
971
+ this.services.get(data.sessionId)?.stopAll();
972
+ this.services.delete(data.sessionId);
973
+ this.clearPreviewIdleTimer(data.sessionId);
974
+ this.writePreviewSnapshot(data.sessionId, null);
975
+ void this.tunnelManager?.removeSession(data.sessionId);
976
+ }
846
977
  // Pre-0.4.0 daemons kept a browser profile per session; drop leftovers.
847
978
  rmSync(join(this.kaiHome, "browser", String(data.sessionId).replace(/[^\w.-]/g, "_")), { recursive: true, force: true });
848
979
  return;
849
- case "bridge.verify.login.start":
850
- return this.loginStart(data);
851
- case "bridge.verify.login.done":
852
- return this.loginDone(data);
853
- case "bridge.verify.login.cancel":
854
- return this.loginCancel(data);
855
- case "bridge.verify.evidence.retry":
856
- return this.retryEvidence(data);
857
980
  case "bridge.preview.start":
858
- // A preview the USER started stays up until they stop it (or it
859
- // idles out); one that Verify booted is released after the run.
981
+ // A preview the USER started stays up until they stop it (or it idles out).
860
982
  (this.manualPreviews ??= new Set()).add(data.sessionId);
861
- this.verifyOwnedPreviews?.delete(data.sessionId);
862
983
  return this.previewStart(data);
863
984
  case "bridge.preview.stop":
864
985
  return this.previewStop(data);
986
+ case "bridge.preview.publish":
987
+ return this.previewPublish(data);
988
+ case "bridge.preview.unpublish":
989
+ return this.previewUnpublish(data);
865
990
  case "bridge.preview.keepalive":
866
991
  // Dashboard heartbeat while someone is actually LOOKING at the
867
992
  // preview — makes the idle auto-stop mean real idleness instead
@@ -884,20 +1009,23 @@ export class BridgeDaemon {
884
1009
  * truncation. `companionRemotes` (Server-resolved `{ key: remote }`) is
885
1010
  * how non-github companions get cloned; `note` survives onto error writes.
886
1011
  */
887
- async previewStart({ sessionId, title, repos, sessionUrl = null, companionRemotes = null }) {
1012
+ async previewStart({ sessionId, title, repos, sessionUrl = null, companionRemotes = null, publicMode = false }) {
888
1013
  this.rememberSession(sessionId, { title, sessionUrl, repos });
889
1014
  let lastNote = null;
890
1015
  const skipped = [];
891
- // Every report is also RETURNED: the verify turn boots the preview
892
- // through this same path and needs the final status + preview URLs.
1016
+ // Every report is also RETURNED (callers and tests read the final status + preview URLs).
893
1017
  const report = async (payload) => {
894
1018
  if (payload.note) lastNote = payload.note;
895
- const body = payload.status === "error" ? { ...payload, urls: payload.urls ?? [], previews: payload.previews ?? [], ...(lastNote && !payload.note ? { note: lastNote } : {}), ...(skipped.length && !payload.skipped ? { skipped } : {}) } : payload;
896
- await this.api.sessionPreview(sessionId, body).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
1019
+ const body =
1020
+ payload.status === "error"
1021
+ ? { ...payload, urls: payload.urls ?? [], previews: payload.previews ?? [], ...(lastNote && !payload.note ? { note: lastNote } : {}), ...(skipped.length && !payload.skipped ? { skipped } : {}) }
1022
+ : // A note-only `running` tick must not wipe the skipped rows the boot reported.
1023
+ { ...payload, ...(skipped.length && !payload.skipped ? { skipped } : {}) };
1024
+ await this.reportPreview(sessionId, body);
897
1025
  return body;
898
1026
  };
899
1027
  const fail = (err, ctx = {}) => report(toPreviewErrorPayload(err, { ...ctx, skipped }));
900
- await report({ status: "starting" });
1028
+ await report({ status: "starting", ...(publicMode ? { note: "Restarting with the public address…" } : {}) });
901
1029
  let runner = null;
902
1030
  try {
903
1031
  // Pass 1 — resolve every session repo (checkout, env, config).
@@ -950,6 +1078,7 @@ export class BridgeDaemon {
950
1078
  const remotes = companionRemotes && typeof companionRemotes === "object" ? companionRemotes : {};
951
1079
  const { companions } = await collectCompanions({
952
1080
  roots: resolved.map((r) => ({ key: r.key, config: r.config })),
1081
+ extra: this.reverseCompanions(resolved),
953
1082
  maxDepth: COMPANION_MAX_DEPTH,
954
1083
  loadConfig: async (key) => {
955
1084
  try {
@@ -978,11 +1107,38 @@ export class BridgeDaemon {
978
1107
  }
979
1108
  const bootable = companions.filter((c) => c.config);
980
1109
 
1110
+ // Public mode: one hostname per service (own repos + companions) from
1111
+ // the Server, which also decides which other public session loses its
1112
+ // link on this device — stopped here, before this one boots.
1113
+ let publicHosts = null;
1114
+ if (publicMode) {
1115
+ await report({ status: "starting", note: "Getting the public address…" });
1116
+ const services = [
1117
+ ...bootable.flatMap((c) => Object.values(c.config.services).map((s) => ({ repoKey: c.key, service: s.name, sessionRepo: false }))),
1118
+ ...resolved.flatMap((r) => Object.values(r.config.services).map((s) => ({ repoKey: r.key, service: s.name, sessionRepo: true }))),
1119
+ ];
1120
+ const answer = await this.api.publicHosts({ sessionId, services });
1121
+ for (const id of answer?.displaced || []) if (id !== sessionId) await this.previewStop({ sessionId: id });
1122
+ publicHosts = {};
1123
+ for (const h of answer?.hosts || []) (publicHosts[String(h.repoKey).toLowerCase()] ??= {})[h.service] = h.hostname;
1124
+ this.tunnel.ensureRunning(answer.tunnel);
1125
+ }
1126
+ const hostsFor = (key) => publicHosts?.[String(key).toLowerCase()] ?? null;
1127
+
981
1128
  runner = this.runnerFor(sessionId);
982
1129
  // Pass 3 — ports for EVERY config (companions + session repos) before
983
1130
  // any boot, so `${port:x}` cross-references resolve whatever the order.
984
- for (const c of bootable) await runner.assignPorts(c.group.primary.path, c.config, { mode: "local", repoKey: c.key });
985
- for (const r of resolved) await runner.assignPorts(r.cwd, r.config, { mode: r.mode, repoKey: r.key, envSource: r.envSource });
1131
+ for (const c of bootable) await runner.assignPorts(c.group.primary.path, c.config, { mode: "local", repoKey: c.key, publicHosts: hostsFor(c.key) });
1132
+ for (const r of resolved) await runner.assignPorts(r.cwd, r.config, { mode: r.mode, repoKey: r.key, envSource: r.envSource, publicHosts: hostsFor(r.key) });
1133
+ // The public addresses answer as soon as the dev servers do: routes go
1134
+ // in BEFORE the boot (cloudflared → the service's gateway port).
1135
+ if (publicMode) {
1136
+ const routes = Object.entries(runner.publicHosts || {})
1137
+ .filter(([name]) => runner.ports[name])
1138
+ .map(([name, hostname]) => ({ hostname, port: runner.ports[name], protocol: runner.meta.get(name)?.protocol || "http" }));
1139
+ await this.tunnel.setRoutes(sessionId, routes);
1140
+ await report({ status: "starting", note: "Public address ready. Starting the services…" });
1141
+ }
986
1142
 
987
1143
  // Pass 4 — boot companions (deepest first, local mode: an already
988
1144
  // running dev server of that repo is adopted), then the session repos
@@ -1013,19 +1169,189 @@ export class BridgeDaemon {
1013
1169
  }
1014
1170
  }
1015
1171
  const urls = [...previews, ...companionPreviews];
1016
- // The landing page is a session repo's preview (never a companion's).
1017
- const landing = previews[0] ?? companionPreviews[0] ?? null;
1018
- this.armPreviewIdleTimer(sessionId);
1019
- return report({ status: "running", previews: urls, urls, landingUrl: landing?.url ?? null, ...(skipped.length ? { skipped } : {}) });
1172
+ // The landing page: the session repo's web preview, else the first web
1173
+ // preview at all (a Server ticket lands on its Frontend companion),
1174
+ // else whatever the session repo serves.
1175
+ const landing = previews.find((p) => p.kind !== "api") ?? urls.find((p) => p.kind !== "api") ?? previews[0] ?? companionPreviews[0] ?? null;
1176
+ const running = { status: "running", previews: urls, urls, landingUrl: landing?.url ?? null, ...(skipped.length ? { skipped } : {}) };
1177
+ // `running` goes out as soon as the dev servers answer — BEFORE the
1178
+ // warm-up: the Server's booting deadline (3 min) and the dashboard
1179
+ // must not wait out a cold compile (10 min on a big Vite app). The
1180
+ // gateway keeps holding document requests on the "starting" page
1181
+ // until the app is warm, so nobody actually lands on a cold server.
1182
+ await report(running);
1183
+ this.writePreviewSnapshot(sessionId, { previews: urls, urls, landingUrl: landing?.url ?? null, manual: !!this.manualPreviews?.has(sessionId), runner: runner.snapshot(), savedAt: new Date().toISOString() });
1184
+ // Warm the app ONCE before anyone is let in: a cold dev server compiles
1185
+ // its whole module graph on the first page load. Done here,
1186
+ // sequentially, the agent and the user's tabs all arrive at a warm
1187
+ // server instead of stampeding a cold one. Progress rides along as a
1188
+ // `running` note; the same body again once warm clears the note.
1189
+ const gen = this.bumpPreviewGeneration(sessionId);
1190
+ return this.warmPreview(sessionId, landing, runner, report).then((warmed) => {
1191
+ // Stopped or replaced while warming: nothing to re-announce, no idle
1192
+ // timer for a runner that is gone — and the awaiting caller must
1193
+ // never take the pre-stop body as a live preview.
1194
+ if (this.services.get(sessionId) !== runner) {
1195
+ return { status: "stopped", urls: [], previews: [], error: "The preview was stopped while it was starting." };
1196
+ }
1197
+ this.armPreviewIdleTimer(sessionId);
1198
+ // Made public (or local) while warming: that path announced the current
1199
+ // addresses; re-announcing THIS body would roll them back.
1200
+ return warmed && this.previewGeneration.get(sessionId) === gen ? report(running) : running;
1201
+ });
1020
1202
  } catch (err) {
1021
1203
  this.log("error", "preview.start.failed", { sessionId, error: err.message, code: err?.code });
1022
1204
  runner?.stopAll();
1023
1205
  this.services.delete(sessionId);
1024
1206
  this.clearPreviewIdleTimer(sessionId);
1207
+ if (publicMode) await this.tunnel.removeSession(sessionId);
1025
1208
  return fail(err);
1026
1209
  }
1027
1210
  }
1028
1211
 
1212
+ /**
1213
+ * Which announcement of a session's addresses is current. A warm-up that
1214
+ * finishes after "Make public" swapped the addresses must not re-announce
1215
+ * the pre-publish body (seen 2026-09-21: the public URLs vanished 3 s after
1216
+ * they appeared).
1217
+ */
1218
+ bumpPreviewGeneration(sessionId) {
1219
+ this.previewGeneration ??= new Map();
1220
+ const gen = (this.previewGeneration.get(sessionId) ?? 0) + 1;
1221
+ this.previewGeneration.set(sessionId, gen);
1222
+ return gen;
1223
+ }
1224
+
1225
+ /** Lazily built: embedded hosts and tests build a daemon without the constructor. */
1226
+ get tunnel() {
1227
+ return (this.tunnelManager ??= new TunnelManager({ kaiHome: this.kaiHome, log: (...a) => this.log(...a) }));
1228
+ }
1229
+
1230
+ /**
1231
+ * "Make public": the preview re-boots with the public addresses (the web
1232
+ * bundle bakes the API origin in at boot). Needs `cloudflared` — on PATH,
1233
+ * downloaded before, or downloaded now; else the dashboard gets a
1234
+ * `dependency_missing` card with the install hint and a Retry.
1235
+ */
1236
+ async previewPublish(data) {
1237
+ const { sessionId } = data;
1238
+ (this.manualPreviews ??= new Set()).add(sessionId);
1239
+ try {
1240
+ // `this.resolveCloudflared` is injectable for tests (the default may download).
1241
+ this.tunnel.binary = await (this.resolveCloudflared ?? resolveCloudflared)({ kaiHome: this.kaiHome, device: this.config?.device?.name || "this device", log: (...a) => this.log(...a) });
1242
+ } catch (err) {
1243
+ await this.reportPreview(sessionId, toPreviewErrorPayload(err));
1244
+ return;
1245
+ }
1246
+ this.rememberSession(sessionId, data);
1247
+ // A running preview goes public IN PLACE; nothing running → a normal boot in public mode.
1248
+ if (this.services.has(sessionId) && this.readPreviewSnapshots()[sessionId]) return this.previewRepublish({ sessionId, makePublic: true });
1249
+ return this.previewStart({ ...data, publicMode: true });
1250
+ }
1251
+
1252
+ /** "Stop sharing": back to local addresses, the preview keeps running. Nothing running → a plain stop. */
1253
+ async previewUnpublish({ sessionId }) {
1254
+ if (this.services.has(sessionId) && this.readPreviewSnapshots()[sessionId]) return this.previewRepublish({ sessionId, makePublic: false });
1255
+ return this.previewStop({ sessionId });
1256
+ }
1257
+
1258
+ /**
1259
+ * Switch a LIVE preview between local and public addresses. Routes go in
1260
+ * first, so the link answers at once (the gateway's "starting" page while
1261
+ * the web app restarts); then only the services that bake a `${url:…}`
1262
+ * restart (`ServiceRunner.setPublicHosts`) — an API or an adopted dev
1263
+ * server is not touched. Measured 2026-09-21: a full re-boot of web + a
1264
+ * worktree Server took ~85 s, the web restart alone ~30 s.
1265
+ */
1266
+ async previewRepublish({ sessionId, makePublic }) {
1267
+ // Two flips of one session must never overlap: on 2026-09-21 a doubled
1268
+ // "Make public" (19 ms apart) had the second flip stop the web app the
1269
+ // first had just restarted, and the preview died as "did not come
1270
+ // back". The same direction joins the running flip; the opposite waits.
1271
+ const flips = (this.republishing ??= new Map());
1272
+ const current = flips.get(sessionId);
1273
+ if (current?.makePublic === makePublic) return current.promise;
1274
+ if (current) {
1275
+ await current.promise.catch(() => {});
1276
+ if (!this.services.has(sessionId)) return;
1277
+ }
1278
+ const promise = this.runPreviewRepublish({ sessionId, makePublic }).finally(() => {
1279
+ if (flips.get(sessionId)?.promise === promise) flips.delete(sessionId);
1280
+ });
1281
+ flips.set(sessionId, { makePublic, promise });
1282
+ return promise;
1283
+ }
1284
+
1285
+ async runPreviewRepublish({ sessionId, makePublic }) {
1286
+ const runner = this.services.get(sessionId);
1287
+ const snap = this.readPreviewSnapshots()[sessionId];
1288
+ const report = (body) => this.reportPreview(sessionId, body).then(() => body);
1289
+ this.clearPreviewIdleTimer(sessionId);
1290
+ // A warm-up still running from the start (a cold Vite compile) ticks
1291
+ // `running` with the LOCAL urls every 20 s — mid-flip that painted a
1292
+ // "Public link" card with no link (2026-09-21). Retire it: a new
1293
+ // generation mutes its ticks, and closing its browser ends it.
1294
+ this.bumpPreviewGeneration(sessionId);
1295
+ this.prewarming?.get(sessionId)?.close?.();
1296
+ await report({ status: "starting", previews: snap.urls ?? snap.previews ?? [], note: makePublic ? "Getting the public address…" : "Switching back to local addresses…" });
1297
+ try {
1298
+ let hosts = null;
1299
+ if (makePublic) {
1300
+ const sessionKeys = new Set((this.sessionMeta?.get(sessionId)?.repos || []).map((k) => String(k).toLowerCase()));
1301
+ const services = [...runner.meta].filter(([, m]) => m.repoKey).map(([name, m]) => ({ repoKey: m.repoKey, service: name, sessionRepo: sessionKeys.has(String(m.repoKey).toLowerCase()) }));
1302
+ const answer = await this.api.publicHosts({ sessionId, services });
1303
+ for (const id of answer?.displaced || []) if (id !== sessionId) await this.previewStop({ sessionId: id });
1304
+ hosts = Object.fromEntries((answer?.hosts || []).map((h) => [h.service, h.hostname]));
1305
+ this.tunnel.ensureRunning(answer.tunnel);
1306
+ const routes = Object.entries(hosts).filter(([name]) => runner.ports[name]).map(([name, hostname]) => ({ hostname, port: runner.ports[name], protocol: runner.meta.get(name)?.protocol || "http" }));
1307
+ await this.tunnel.setRoutes(sessionId, routes);
1308
+ await report({ status: "starting", note: "Public address ready. Restarting the web app with it…" });
1309
+ } else {
1310
+ await this.tunnelManager?.removeSession(sessionId);
1311
+ }
1312
+ await runner.setPublicHosts(hosts);
1313
+ const urls = (snap.urls ?? snap.previews ?? []).map(({ publicUrl: _old, ...p }) => (hosts?.[p.name] ? { ...p, publicUrl: `https://${hosts[p.name]}` } : p));
1314
+ const landing = urls.find((p) => p.kind !== "api") ?? urls[0] ?? null;
1315
+ const running = { status: "running", previews: urls, urls, landingUrl: landing?.url ?? null };
1316
+ await report(running);
1317
+ this.writePreviewSnapshot(sessionId, { ...snap, previews: urls, urls, runner: runner.snapshot(), savedAt: new Date().toISOString() });
1318
+ const gen = this.bumpPreviewGeneration(sessionId);
1319
+ return this.warmPreview(sessionId, landing, runner, report).then((warmed) => {
1320
+ if (this.services.get(sessionId) !== runner) return { status: "stopped", urls: [], previews: [] };
1321
+ this.armPreviewIdleTimer(sessionId);
1322
+ return warmed && this.previewGeneration.get(sessionId) === gen ? report(running) : running;
1323
+ });
1324
+ } catch (err) {
1325
+ this.log("error", "preview.republish.failed", { sessionId, makePublic, error: err.message, code: err?.code });
1326
+ runner.stopAll();
1327
+ this.services.delete(sessionId);
1328
+ this.writePreviewSnapshot(sessionId, null);
1329
+ await this.tunnelManager?.removeSession(sessionId);
1330
+ return report(toPreviewErrorPayload(err));
1331
+ }
1332
+ }
1333
+
1334
+ /**
1335
+ * Repos on this device whose dev config names a session repo as a
1336
+ * companion and that have a web preview: booted alongside as optional
1337
+ * companions (a Server ticket gets its Frontend, pointed at the ticket's
1338
+ * Server), unless they opt out with `reverseCompanions: false`. Only
1339
+ * checkouts already here — never cloned for this.
1340
+ */
1341
+ reverseCompanions(resolved) {
1342
+ const sessionKeys = new Set(resolved.map((r) => String(r.key).toLowerCase()));
1343
+ const out = [];
1344
+ for (const group of this.repoGroups || []) {
1345
+ const key = String(group.key).toLowerCase();
1346
+ if (sessionKeys.has(key) || !group.primary?.path) continue;
1347
+ const config = readDevConfig(group.primary.path);
1348
+ if (!config || config.error || config.reverseCompanions === false || !config.preview) continue;
1349
+ if (config.services[config.preview]?.kind === "api") continue;
1350
+ if (config.companions.some((c) => sessionKeys.has(c.repo))) out.push({ repo: key, optional: true });
1351
+ }
1352
+ return out;
1353
+ }
1354
+
1029
1355
  /**
1030
1356
  * Config resolution order: this worktree's own config, then the PRIMARY
1031
1357
  * checkout's (a repo verified by the setup agent has its dev.yaml on an
@@ -1066,7 +1392,7 @@ export class BridgeDaemon {
1066
1392
 
1067
1393
  /**
1068
1394
  * The session's worktree for a repo: the exact slug when the title is
1069
- * known; otherwise (sign-in commands carry only the session id) the one
1395
+ * known; otherwise (a command that carries only the session id) the one
1070
1396
  * directory under `~/.kai/worktrees/<repo>/` that ends in the session's
1071
1397
  * id suffix — slugs are `<title>-<last 8 of the id>`, unique per session.
1072
1398
  */
@@ -1089,8 +1415,8 @@ export class BridgeDaemon {
1089
1415
  * `PreviewError` carrying the classified log line + code when a service
1090
1416
  * never becomes ready — a "running" preview must not 404.
1091
1417
  */
1092
- async bootRepoWithConfig(runner, cwd, config, mode, repoKey = null, opts = {}) {
1093
- const started = await runner.start(cwd, config, { mode, repoKey, ...opts });
1418
+ async bootRepoWithConfig(runner, cwd, config, mode, repoKey = null) {
1419
+ const started = await runner.start(cwd, config, { mode, repoKey });
1094
1420
  const dead = started.services.find((s) => !s.adopted && !s.ready);
1095
1421
  if (dead) {
1096
1422
  throw new PreviewError(`${dead.name} did not start${dead.error ? ` — ${dead.error}` : ""}`, {
@@ -1168,15 +1494,100 @@ export class BridgeDaemon {
1168
1494
  }
1169
1495
  runner.stopAll();
1170
1496
  this.services.delete(sessionId);
1497
+ this.writePreviewSnapshot(sessionId, null);
1498
+ void this.tunnelManager?.removeSession(sessionId);
1171
1499
  this.log("info", "preview.idle.stopped", { sessionId });
1172
- this.api
1173
- .sessionPreview(sessionId, { status: "stopped", urls: [], previews: [], errorCode: "idle_stopped", error: "Stopped automatically after 30 minutes." })
1174
- .catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
1500
+ void this.reportPreview(sessionId, { status: "stopped", urls: [], previews: [], errorCode: "idle_stopped", error: "Stopped automatically after 30 minutes." });
1175
1501
  }, this.previewIdleMs ?? 30 * 60 * 1000);
1176
1502
  t.unref?.();
1177
1503
  this.previewIdleTimers.set(sessionId, t);
1178
1504
  }
1179
1505
 
1506
+ /**
1507
+ * The warm-up gate. Loads the landing page once, headless, BEFORE the
1508
+ * preview is reported running. While it runs, the service's gateway holds
1509
+ * browser tabs on a reloading "Kai is starting the preview" page (they
1510
+ * would otherwise pile onto the cold compile), and the dashboard shows
1511
+ * the elapsed time. Bounded by PREWARM_TIMEOUT_MS; a timeout is logged
1512
+ * and the preview still opens. Skipped for a runner already warmed, on
1513
+ * a device without a browser, and for previews with no web landing page.
1514
+ */
1515
+ async warmPreview(sessionId, landing, runner, report = async () => {}) {
1516
+ // Single-flight per session: two callers arriving while the app compiles share one run.
1517
+ this.prewarming ??= new Map();
1518
+ const inFlight = this.prewarming.get(sessionId);
1519
+ if (inFlight?.promise) return inFlight.promise;
1520
+ const entry = { close: () => {}, promise: null };
1521
+ this.prewarming.set(sessionId, entry);
1522
+ entry.promise = this.warmPreviewOnce(sessionId, landing, runner, report, entry).finally(() => {
1523
+ if (this.prewarming?.get(sessionId) === entry) this.prewarming.delete(sessionId);
1524
+ });
1525
+ return entry.promise;
1526
+ }
1527
+
1528
+ async warmPreviewOnce(sessionId, landing, runner, report, entry) {
1529
+ const url = landing?.url ?? null;
1530
+ if (!url || !runner || runner.warmed || this.stopped) return false;
1531
+ // A flip (make public / stop sharing) bumps the generation: this warm-up
1532
+ // then belongs to a web app that is being replaced — no more reports.
1533
+ const gen = this.previewGeneration?.get(sessionId) ?? 0;
1534
+ const current = () => !this.stopped && this.services.get(sessionId) === runner && (this.previewGeneration?.get(sessionId) ?? 0) === gen;
1535
+ const pw = this.loadPlaywright();
1536
+ if (typeof pw?.chromium?.launch !== "function") return false;
1537
+ const browser = await this.ensurePreviewBrowser().catch(() => null);
1538
+ if (!browser?.ok) return false;
1539
+ const gateway = landing?.name ? runner.gatewayFor?.(landing.name) : null;
1540
+ const startedAt = Date.now();
1541
+ const elapsed = () => {
1542
+ const sec = Math.round((Date.now() - startedAt) / 1000);
1543
+ return sec >= 60 ? `${Math.floor(sec / 60)}m ${String(sec % 60).padStart(2, "0")}s` : `${sec}s`;
1544
+ };
1545
+ const noteFor = () => `Compiling the app for the first time (${elapsed()})… a cold dev server can take a few minutes; later starts are fast.`;
1546
+ // The preview is already reported `running` (urls up) — the note rides
1547
+ // on that status so it never regresses to `starting`.
1548
+ const tick = async () => {
1549
+ if (!current()) return; // never overtake a `stopped` report, nor a flip in progress
1550
+ const note = noteFor();
1551
+ gateway?.hold(note);
1552
+ await report({ status: "running", note });
1553
+ };
1554
+ await tick();
1555
+ const ticker = setInterval(() => void tick(), 20_000);
1556
+ ticker.unref?.();
1557
+ let instance = null;
1558
+ entry.close = () => instance?.close().catch(() => {});
1559
+ try {
1560
+ instance = await pw.chromium.launch(launchOptionsFor({ headless: true, browser: browser.browser === "chrome" ? "chrome" : null }));
1561
+ const context = await instance.newContext({ extraHTTPHeaders: { [WARMUP_HEADER]: "1" } });
1562
+ const page = await context.newPage();
1563
+ // `domcontentloaded` is the point where every statically imported
1564
+ // module has been transformed and run — that is the compile the warm-up
1565
+ // exists to pay for. `load` additionally waits for fonts, images and
1566
+ // late chunks, which on a big dev build pushed a cold warm-up past its
1567
+ // whole budget (measured 2026-09-15: 10 min → "Timeout exceeded").
1568
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: PREWARM_TIMEOUT_MS });
1569
+ // Let lazy chunks and the app's first API calls land too.
1570
+ await new Promise((r) => setTimeout(r, 5_000));
1571
+ // Stopped, replaced or torn down while we waited: stop() has already
1572
+ // written the handoff snapshot from the live runner — a rewrite from a
1573
+ // detached one would record no services and the next daemon would
1574
+ // reap the warm servers as orphans.
1575
+ if (!current()) return false;
1576
+ runner.warmed = true;
1577
+ this.log("info", "preview.warmed", { sessionId, ms: Date.now() - startedAt });
1578
+ // A daemon restart re-adopts this preview as warm (no second gate).
1579
+ const prev = this.readPreviewSnapshots()[sessionId];
1580
+ if (prev) this.writePreviewSnapshot(sessionId, { ...prev, runner: runner.snapshot(), savedAt: new Date().toISOString() });
1581
+ } catch (err) {
1582
+ this.log("warn", "preview.warm.failed", { sessionId, ms: Date.now() - startedAt, error: String(err?.message || err).split("\n")[0] });
1583
+ } finally {
1584
+ clearInterval(ticker);
1585
+ gateway?.release();
1586
+ await instance?.close().catch(() => {});
1587
+ }
1588
+ return true;
1589
+ }
1590
+
1180
1591
  /** Any ESTABLISHED connection on one of the runner's (non-adopted) ports? Injectable for tests. */
1181
1592
  async previewHasConnections(runner) {
1182
1593
  const count = this.countConnections ?? ((port) => establishedConnections(port));
@@ -1195,13 +1606,12 @@ export class BridgeDaemon {
1195
1606
 
1196
1607
  async previewStop({ sessionId }) {
1197
1608
  this.manualPreviews?.delete(sessionId);
1198
- this.verifyOwnedPreviews?.delete(sessionId);
1199
- // A sign-in window for this session has nothing left to sign in to.
1200
- await this.teardownLogins((l) => l.sessionId === sessionId, "preview stopped");
1201
1609
  this.services.get(sessionId)?.stopAll();
1202
1610
  this.services.delete(sessionId);
1203
1611
  this.clearPreviewIdleTimer(sessionId);
1204
- await this.api.sessionPreview(sessionId, { status: "stopped", urls: [], previews: [] }).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
1612
+ this.writePreviewSnapshot(sessionId, null);
1613
+ await this.tunnelManager?.removeSession(sessionId);
1614
+ await this.reportPreview(sessionId, { status: "stopped", urls: [], previews: [] });
1205
1615
  }
1206
1616
 
1207
1617
  /**
@@ -1216,6 +1626,7 @@ export class BridgeDaemon {
1216
1626
  this.log("warn", "preview.service.died", { sessionId, name: info.name, code: info.code, errorCode: info.errorCode });
1217
1627
  runner.stopAll();
1218
1628
  this.services.delete(sessionId);
1629
+ this.writePreviewSnapshot(sessionId, null);
1219
1630
  this.clearPreviewIdleTimer(sessionId);
1220
1631
  return this.api
1221
1632
  .sessionPreview(
@@ -1236,15 +1647,12 @@ export class BridgeDaemon {
1236
1647
  let runner = this.services.get(sessionId);
1237
1648
  if (!runner) {
1238
1649
  // Stable ports (hash of repo + service in 43000-43999, when free) keep
1239
- // a saved sign-in's origins matching from one session to the next.
1650
+ // a service's URL the same from one session to the next.
1240
1651
  runner = new ServiceRunner({
1241
1652
  kaiHome: this.kaiHome,
1242
1653
  sessionId,
1243
1654
  log: this.log,
1244
- onStatus: (message) => {
1245
- this.log("info", "preview.status", { sessionId, message });
1246
- this.previewStatusListeners?.get(sessionId)?.(message);
1247
- },
1655
+ onStatus: (message) => this.log("info", "preview.status", { sessionId, message }),
1248
1656
  preferredPort: ({ repoKey, service }) => preferredStablePort(repoKey, service),
1249
1657
  ...(this.describeListener ? { describeListener: this.describeListener } : {}),
1250
1658
  ...(this.previewSettleMs != null ? { settleMs: this.previewSettleMs } : {}),
@@ -1336,11 +1744,6 @@ export class BridgeDaemon {
1336
1744
  this.running.set(turnId, entry);
1337
1745
  const releaseAwake = keepAwake();
1338
1746
  let outcome = null;
1339
- // Verify turns (`agent: kai-verifier`): preview booted up front, browser
1340
- // evidence collected into an artifacts dir, report uploaded after the
1341
- // turn — see prepareVerifyTurn / finishVerification.
1342
- const isVerify = turn.agent === "kai-verifier";
1343
- let verify = null;
1344
1747
  // Everything `kai-bridge ps` shows for this turn; the runner pid follows at spawn.
1345
1748
  const session = this.rememberSession(turn.sessionId, { title: turn.title, sessionUrl: turn.sessionUrl, repos: turn.repos });
1346
1749
  this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null, harness: turn.harness ?? null, profileId: turn.profileId ?? null, startedAt: new Date().toISOString(), ...session });
@@ -1353,88 +1756,32 @@ export class BridgeDaemon {
1353
1756
  // local paths — the prompt lists them.
1354
1757
  const workDir = bound[0]?.cwd;
1355
1758
  if (!workDir) throw new Error("Turn has no repositories.");
1356
- const repoNote = isVerify ? this.verifyRepoBrief(bound) : this.repoBrief(turn, bound);
1357
- let previewNote = "";
1358
- let mcpServers = turn.mcpServers;
1359
- if (isVerify) {
1360
- verify = await this.prepareVerifyTurn(turn, bound);
1361
- if (!verify.ok) {
1362
- // Nothing to test against (preview dead) or a sign-in wall the
1363
- // user must clear first: file a blocked report and close the turn
1364
- // cleanly without spending an agent run.
1365
- await this.api
1366
- .turnVerification(turnId, { ...buildVerificationPayload(null, [], { fallbackReason: verify.error }), ...(verify.blocked || {}) })
1367
- .catch((err) => this.log("warn", "verify.report.failed", { turnId, error: err.message }));
1368
- outcome = {
1369
- status: "completed",
1370
- result: null,
1371
- changes: bound.map((b) => ({ key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, files: [], push: false })),
1372
- profileId: profile.id,
1373
- };
1374
- return;
1375
- }
1376
- previewNote = verify.note;
1377
- // The evidence dir is where a restart mid-run finds partial footage.
1378
- this.rememberInflight(turnId, { artifactsDir: verify.artifactsDir });
1379
- mcpServers = [
1380
- ...(turn.mcpServers || []),
1381
- previewMcpServer(RUNNER_DIR, {
1382
- outputDir: verify.artifactsDir,
1383
- // `storage` = browser_storage_state for the final sign-in refresh;
1384
- // restoring an arbitrary state file is never the agent's call.
1385
- caps: ["devtools", "testing", "storage"],
1386
- secretsFile: verify.secretsFile,
1387
- storageStateFile: verify.storageStateFile,
1388
- allowedOrigins: verify.storageStateFile ? verify.allowedOrigins : null,
1389
- disabledTools: ["browser_set_storage_state"],
1390
- ignoreHttpsErrors: !!verify.ignoreHttpsErrors,
1391
- }),
1392
- ];
1393
- await this.postVerifyStage(turnId, "verifying");
1394
- } else {
1395
- // Previews are manual-only (dashboard "Start preview") — a turn
1396
- // never boots dev servers on its own. When the user already has a
1397
- // preview running for this session, describe it to the agent and
1398
- // hand it the Playwright MCP so it can verify in a real browser.
1399
- const live = await this.describeLivePreview(turn, bound, batcher);
1400
- previewNote = live.note;
1401
- if (live.hasLivePreview) mcpServers = [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)];
1402
- }
1759
+ const repoNote = this.repoBrief(turn, bound);
1760
+ // Previews are manual-only (dashboard "Start preview") — a turn
1761
+ // never boots dev servers on its own. When the user already has a
1762
+ // preview running for this session, describe it to the agent and
1763
+ // hand it the Playwright MCP so it can check its work in a real browser.
1764
+ const live = await this.describeLivePreview(turn, bound, batcher);
1765
+ const previewNote = live.note;
1766
+ const mcpServers = live.hasLivePreview ? [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)] : turn.mcpServers;
1403
1767
  const res = await runTurn({
1404
1768
  turn: { ...turn, task: `${turn.task}${repoNote}${previewNote}`, mcpServers },
1405
1769
  profile,
1406
1770
  workDir,
1407
1771
  kaiHome: this.kaiHome,
1408
- // The verify MCP's request policy (KAI_VERIFY_*) — host-only, never in the prompt.
1409
- extraEnv: verify?.env ?? null,
1410
1772
  signal: ctrl.signal,
1411
1773
  onSpawn: (handle) => {
1412
1774
  entry.control = handle.control;
1413
1775
  if (Number.isInteger(handle.pid)) this.rememberInflight(turnId, { pid: handle.pid });
1414
1776
  },
1415
- onEvent: (ev) => {
1416
- if (verify) {
1417
- // The tester is using the preview — it must not idle-stop
1418
- // under it. And the report carries LOCAL paths: keep it here,
1419
- // the uploaded version goes out after the turn.
1420
- this.armPreviewIdleTimer(turn.sessionId);
1421
- if (ev?.type === "verify_report") {
1422
- verify.report = ev.report && typeof ev.report === "object" ? ev.report : null;
1423
- return;
1424
- }
1425
- // Injected sign-in values (cookies, tokens) must never reach
1426
- // the Server in a tool row or snapshot.
1427
- if (verify.redact?.size) ev = redactDeep(ev, verify.redact);
1428
- }
1429
- batcher.push(ev);
1430
- },
1777
+ onEvent: (ev) => batcher.push(ev),
1431
1778
  onLog: (l) => this.log("debug", "runner", { line: l.slice(0, 500) }),
1432
1779
  });
1433
1780
  await batcher.flush();
1434
1781
  const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
1435
- // Plan and verify turns are read-only: worktrees are restored, local
1436
- // checkouts only reported, nothing is ever committed or pushed.
1437
- const readOnlyTurn = !!turn.planMode || isVerify;
1782
+ // Plan turns are read-only: worktrees are restored, local checkouts
1783
+ // only reported, nothing is ever committed or pushed.
1784
+ const readOnlyTurn = !!turn.planMode;
1438
1785
  const changes = await Promise.all([...bound, ...this.adoptSessionWorktrees(turn, bound)].map(async (b) => {
1439
1786
  ensureCommitExcludes(b.cwd, { allowDevConfig: !!turn.allowDevConfig });
1440
1787
  // A read-only turn must leave the worktree as it found it — see
@@ -1442,14 +1789,13 @@ export class BridgeDaemon {
1442
1789
  if (readOnlyTurn) {
1443
1790
  const leaked = b.mode === "worktree" ? discardChanges(b.cwd).discarded : collectChanges(b.cwd).files;
1444
1791
  if (leaked.length > 0) {
1445
- const mode = turn.planMode ? "Plan mode" : "Verification";
1446
- this.log("warn", `${turn.planMode ? "plan" : "verify"}.changes`, { turnId, repo: b.key, mode: b.mode, files: leaked.length, discarded: b.mode === "worktree" });
1792
+ this.log("warn", "plan.changes", { turnId, repo: b.key, mode: b.mode, files: leaked.length, discarded: b.mode === "worktree" });
1447
1793
  batcher.push({
1448
1794
  type: "text",
1449
1795
  message:
1450
1796
  b.mode === "worktree"
1451
- ? `${mode} is read-only — ${leaked.length} file change${leaked.length === 1 ? "" : "s"} made during ${turn.planMode ? "planning" : "verification"} ${leaked.length === 1 ? "was" : "were"} discarded${turn.planMode ? "; the build starts from the plan" : ""}.`
1452
- : `${mode} is read-only, but ${leaked.length} file change${leaked.length === 1 ? "" : "s"} landed in your local checkout of ${b.key} — review them${turn.planMode ? " before building" : ""}.`,
1797
+ ? `Plan mode is read-only — ${leaked.length} file change${leaked.length === 1 ? "" : "s"} made during planning ${leaked.length === 1 ? "was" : "were"} discarded; the build starts from the plan.`
1798
+ : `Plan mode is read-only, but ${leaked.length} file change${leaked.length === 1 ? "" : "s"} landed in your local checkout of ${b.key} — review them before building.`,
1453
1799
  });
1454
1800
  }
1455
1801
  }
@@ -1471,9 +1817,7 @@ export class BridgeDaemon {
1471
1817
  };
1472
1818
  const push = shouldPush
1473
1819
  ? await this.withGitAuth(b.key, pushOnce).catch((err) => err.push ?? { committed: false, pushed: false, branch: b.branch, error: err.message })
1474
- : isVerify
1475
- ? false
1476
- : null;
1820
+ : null;
1477
1821
  // `cwd` travels with the change so the dashboard can point at
1478
1822
  // work that stayed on this machine (files but no push).
1479
1823
  // Pushed → the commit list + diff stat travel with the change so the
@@ -1485,10 +1829,6 @@ export class BridgeDaemon {
1485
1829
  // flush; land them before the result closes the turn (the Server
1486
1830
  // answers 410 for events on an ended turn).
1487
1831
  await batcher.flush();
1488
- if (verify) {
1489
- const finished = await this.finishVerification(turn, { ...verify, workDir, bound }, res, ctrl.signal.aborted);
1490
- await this.settleVerifyPreview(turn, finished?.status).catch((err) => this.log("warn", "verify.preview.release.failed", { turnId, error: err.message }));
1491
- }
1492
1832
  // Built OUTSIDE the report call: if posting the result throws, the
1493
1833
  // catch below must not turn a finished turn into a failed one. The
1494
1834
  // work is already committed and pushed at this point.
@@ -1496,10 +1836,10 @@ export class BridgeDaemon {
1496
1836
  status: ctrl.signal.aborted ? "cancelled" : res.rateLimited ? "rate_limited" : res.code === 0 ? "completed" : "failed",
1497
1837
  exitCode: res.code,
1498
1838
  ...(res.errorCode ? { errorCode: res.errorCode } : {}),
1499
- result: verify?.redact?.size ? redactDeep(res.result, verify.redact) : res.result,
1839
+ result: res.result,
1500
1840
  // The runner's own failure text (e.g. the engine's usage-limit
1501
1841
  // message) — the Server prefers this over its generic fallback.
1502
- ...(res.lastError && res.code !== 0 ? { error: redactDeep(res.lastError.replace(/^acp-runner: /, "").slice(0, 500), verify?.redact) } : {}),
1842
+ ...(res.lastError && res.code !== 0 ? { error: res.lastError.replace(/^acp-runner: /, "").slice(0, 500) } : {}),
1503
1843
  changes,
1504
1844
  profileId: profile.id,
1505
1845
  };
@@ -1520,845 +1860,23 @@ export class BridgeDaemon {
1520
1860
  })
1521
1861
  .catch((err) => this.log("error", "result.lost", { turnId, error: err.message }));
1522
1862
  }
1523
- // Test credentials and the injected / final sign-in state live on
1524
- // disk only for the duration of the turn.
1525
- for (const f of [verify?.secretsFile, verify?.storageStateFile, verify?.finalStateFile]) if (f) rmSync(f, { force: true });
1526
- verify?.release?.();
1527
1863
  this.forgetInflight(turnId);
1528
1864
  releaseAwake();
1529
1865
  this.running.delete(turnId);
1530
- if ((this.updatePending || this.restartPending) && this.running.size === 0 && this.logins.size === 0) void this.checkForUpdate();
1866
+ if ((this.updatePending || this.restartPending) && this.running.size === 0) void this.checkForUpdate();
1531
1867
  }
1532
1868
  }
1533
1869
 
1534
- // ── verify turns ───────────────────────────────────────────────────
1535
- /** Overridable seam (tests, embedded hosts): a browser the Playwright MCP can launch. */
1870
+ // ── preview browser ────────────────────────────────────────────────
1871
+ /** Overridable seam (tests, embedded hosts): a browser the warm-up and the Playwright MCP can launch. */
1536
1872
  ensurePreviewBrowser() {
1537
1873
  return ensurePreviewBrowser({ runnerDir: RUNNER_DIR, log: this.log });
1538
1874
  }
1539
1875
 
1540
- /** Seams for the sign-in flows (tests inject a fake Playwright / probe / capture). */
1876
+ /** Seam for the warm-up (tests inject a fake Playwright). */
1541
1877
  loadPlaywright() {
1542
1878
  return loadPlaywright(RUNNER_DIR);
1543
1879
  }
1544
- displayAvailable() {
1545
- return displayAvailable();
1546
- }
1547
- probeLogin(opts) {
1548
- return probeLogin(opts);
1549
- }
1550
- captureLogin(opts) {
1551
- return captureLogin(opts);
1552
- }
1553
-
1554
- /** Where a verify turn's injected sign-in state lives (0600, deleted with the turn). */
1555
- loginStateDir() {
1556
- return join(this.kaiHome, "state", "preview-login");
1557
- }
1558
-
1559
- /** The agent's final browser-state export — inside the turn's evidence dir (the MCP's only writable root besides the repo). */
1560
- finalStateFileFor(artifactsDir) {
1561
- return join(artifactsDir, "state", "final-storage-state.json");
1562
- }
1563
-
1564
- /** Best-effort stage transition for the dashboard's Verify card (booting → verifying → saving). */
1565
- async postVerifyStage(turnId, stage, note) {
1566
- // The note often relays the agent's last line — markdown emphasis
1567
- // ("**Confirming…**") must not reach the card as literal asterisks.
1568
- const plain = typeof note === "string" ? note.replace(/[#*`>_]/g, "").replace(/\s+/g, " ").trim().slice(0, 200) : "";
1569
- try {
1570
- await this.api.turnVerificationStage(turnId, { stage, ...(plain ? { note: plain } : {}) });
1571
- } catch (err) {
1572
- this.log("debug", "verify.stage.failed", { turnId, stage, error: err?.message });
1573
- }
1574
- }
1575
-
1576
- /**
1577
- * One probe / refresh of a sign-in at a time per user + repo: two verify
1578
- * turns racing the same record would both refresh it (one 409s) and
1579
- * could probe a preview the other is still booting.
1580
- */
1581
- withVerifyLock(key, fn) {
1582
- return this.acquireVerifyLock(key).then(async (release) => {
1583
- try {
1584
- return await fn();
1585
- } finally {
1586
- release();
1587
- }
1588
- });
1589
- }
1590
-
1591
- /**
1592
- * Acquire the per user+repo verify lock; resolves with `release()`. A
1593
- * turn that injected a saved sign-in keeps it until its final refresh
1594
- * has landed: two agent runs presenting the same refresh token in
1595
- * parallel is exactly what rotation-based IdPs revoke on.
1596
- */
1597
- acquireVerifyLock(key) {
1598
- this.verifyLockHolders ??= new Set(); // keys currently HELD (the map also lists waiters)
1599
- this.verifyLocks ??= new Map();
1600
- const prev = this.verifyLocks.get(key) ?? Promise.resolve();
1601
- let release;
1602
- const held = new Promise((resolve) => {
1603
- let done = false;
1604
- release = () => {
1605
- if (done) return;
1606
- done = true;
1607
- this.verifyLockHolders.delete(key);
1608
- resolve();
1609
- };
1610
- });
1611
- const next = prev.catch(() => {}).then(() => held);
1612
- this.verifyLocks.set(key, next);
1613
- next.finally(() => {
1614
- if (this.verifyLocks.get(key) === next) this.verifyLocks.delete(key);
1615
- });
1616
- return prev
1617
- .catch(() => {})
1618
- .then(() => {
1619
- this.verifyLockHolders.add(key);
1620
- return release;
1621
- });
1622
- }
1623
-
1624
- /** The verifier's repo brief: where the checkouts are, and that they are read-only. */
1625
- verifyRepoBrief(bound) {
1626
- const lines = ["", "", "Repositories in this session (read-only for this verification turn — you cannot commit or push, and every file change is discarded when the turn ends):"];
1627
- for (const b of bound) lines.push(`- ${b.key}: ${b.cwd}${b.branch ? ` (branch ${b.branch}${b.base ? `, base ${b.base}` : ""})` : ""}`);
1628
- return lines.join("\n");
1629
- }
1630
-
1631
- /**
1632
- * dev.yaml `auth.storageState: <command>` — run in the repo checkout,
1633
- * stdout is Playwright storageState JSON (or the path of a JSON file).
1634
- * Replaces the user's saved sign-in for this repo. Null on any failure.
1635
- */
1636
- async storageStateFromCommand(command, cwd) {
1637
- return new Promise((resolveP) => {
1638
- const cb = (err, stdout) => {
1639
- if (err) {
1640
- this.log("warn", "verify.login.command.failed", { error: err.message });
1641
- return resolveP(null);
1642
- }
1643
- resolveP(parseStorageStateOutput(stdout, { read: (p) => readFileSync(p, "utf8") }));
1644
- };
1645
- const opts = { cwd, timeout: 60_000, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, BROWSER: "none" } };
1646
- if (process.platform === "darwin") execFile("/bin/zsh", ["-lc", command], opts, cb);
1647
- else execFile(command, [], { ...opts, shell: true }, cb);
1648
- });
1649
- }
1650
-
1651
- /**
1652
- * Boot (or re-describe) the session's preview, make sure a browser can
1653
- * launch, decide the sign-in (dev.yaml command → saved record → none),
1654
- * PROBE it headless, and lay out the turn's evidence dir + state files +
1655
- * optional secrets file. Resolves `{ ok: false, error, blocked? }`
1656
- * (`blocked` = `{ blockedCode, loginPath?, firstTime? }` for the report)
1657
- * or `{ ok: true, artifactsDir, secretsFile, storageStateFile,
1658
- * finalStateFile, allowedOrigins, injected, redact, note, report: null }`.
1659
- */
1660
- async prepareVerifyTurn(turn, bound = []) {
1661
- const repoKeys = (turn.repos || []).map((r) => r.key).filter(Boolean).sort();
1662
- const lockKey = `${turn.userId ?? turn.ownerUserId ?? "device"}:${repoKeys.join(",")}`;
1663
- if (this.verifyLockHolders?.has(lockKey)) {
1664
- // Another verify run of this repo holds its sign-in — say so instead
1665
- // of sitting on "Starting the app…" until it finishes.
1666
- this.log("info", "verify.lock.wait", { turnId: turn.turnId, lockKey });
1667
- await this.postVerifyStage(turn.turnId, "booting", "Waiting for another verification of this repo on this machine to finish…");
1668
- }
1669
- const release = await this.acquireVerifyLock(lockKey);
1670
- let result;
1671
- try {
1672
- result = await this.prepareVerifyTurnLocked(turn, bound);
1673
- } catch (err) {
1674
- release();
1675
- throw err;
1676
- }
1677
- if (result?.ok && result.injected) {
1678
- // Hold the repo's sign-in for the whole run — released by startTurn's
1679
- // finally (after finishVerification refreshed the record).
1680
- return { ...result, release };
1681
- }
1682
- release();
1683
- return result;
1684
- }
1685
-
1686
- async prepareVerifyTurnLocked(turn, bound) {
1687
- const { turnId, sessionId } = turn;
1688
- // Lazy browser check — setup usually did this; a machine that skipped
1689
- // it downloads Chromium now (best-effort: without a browser the agent
1690
- // reports `blocked` itself).
1691
- const browser = await this.ensurePreviewBrowser().catch((err) => ({ ok: false, error: err?.message }));
1692
- if (!browser?.ok) {
1693
- // No browser, no test — say so with the exact command (Linux/Windows
1694
- // machines without Chrome and a failed Chromium download) instead of
1695
- // letting the agent discover it on its first navigate.
1696
- this.log("warn", "verify.browser.unavailable", { turnId, error: browser?.error });
1697
- return {
1698
- ok: false,
1699
- error: `No browser is available on this machine for the verification: ${browser?.error || "Chromium is not installed."}${browser?.command ? ` Run \`${browser.command}\` on this machine, then retry.` : ""}`,
1700
- blocked: { blockedCode: "no_browser" },
1701
- };
1702
- }
1703
- const channel = browser?.browser === "chrome" ? "chrome" : null;
1704
- // previewStart is idempotent on a live runner (booted services are
1705
- // only re-described) and reports starting/running to the dashboard
1706
- // exactly like the manual button. A cold boot is its own stage, and
1707
- // every runner status line during it (installing, still starting,
1708
- // ready) reaches the Verify card as a `booting` note — the heartbeat.
1709
- const coldBoot = !this.services.has(sessionId);
1710
- if (coldBoot && !this.manualPreviews?.has(sessionId)) (this.verifyOwnedPreviews ??= new Set()).add(sessionId);
1711
- if (coldBoot) await this.postVerifyStage(turnId, "booting");
1712
- const heartbeat = createStageHeartbeat((note) => this.postVerifyStage(turnId, "booting", note), { minIntervalMs: this.stageHeartbeatMs ?? 5_000 });
1713
- this.previewStatusListeners ??= new Map();
1714
- this.previewStatusListeners.set(sessionId, heartbeat.note);
1715
- let preview;
1716
- try {
1717
- preview = await this.previewStart({ sessionId, title: turn.title, repos: turn.repos, sessionUrl: turn.sessionUrl ?? null, companionRemotes: turn.companionRemotes ?? null });
1718
- } finally {
1719
- this.previewStatusListeners?.delete(sessionId);
1720
- heartbeat.stop();
1721
- }
1722
- if (preview?.status !== "running") {
1723
- // The preview's structured error IS the diagnosis: its code becomes the
1724
- // verification's blockedCode (companion_missing, port_busy, deps_failed,
1725
- // …) so the card offers the right verb; `preview_unreachable` only
1726
- // when nothing more specific is known.
1727
- const code = preview?.errorCode && preview.errorCode !== "other" ? preview.errorCode : "preview_unreachable";
1728
- return {
1729
- ok: false,
1730
- error: preview?.error ? `The preview could not be started: ${preview.error}` : "The preview could not be started.",
1731
- blocked: { blockedCode: code },
1732
- };
1733
- }
1734
- // After a fix turn, services that do not hot-reload (`reload: restart`,
1735
- // the default for APIs) must run the fixed code before the probe.
1736
- const restartNotes = await this.restartChangedServices(turn, sessionId);
1737
- const artifactsDir = join(this.kaiHome, "artifacts", String(turnId).replace(/[^\w.-]/g, "_"));
1738
- mkdirSync(artifactsDir, { recursive: true });
1739
- let secretsFile = null;
1740
- let secretNames = [];
1741
- if (turn.verifySecrets && typeof turn.verifySecrets === "object") {
1742
- const dir = join(this.kaiHome, "state", "verify-secrets");
1743
- mkdirSync(dir, { recursive: true });
1744
- const path = join(dir, `${String(turnId).replace(/[^\w.-]/g, "_")}.env`);
1745
- secretNames = writeSecretsFile(path, turn.verifySecrets);
1746
- if (secretNames.length > 0) secretsFile = path;
1747
- }
1748
- const previews = preview.previews || [];
1749
- const runner = this.services.get(sessionId);
1750
- const services = runner?.describeServices?.() ?? previews.map((p) => ({ name: p.name, url: p.url }));
1751
- const previewOrigins = [...new Set([...services.map((s) => normalizeOrigin(s.url)), ...previews.map((p) => normalizeOrigin(p.url))].filter(Boolean))];
1752
- const primaryKey = (turn.repos || [])[0]?.key;
1753
- // The landing page is a session repo's web preview (an API has no UI to land on).
1754
- const webPreviews = previews.filter((p) => p.kind !== "api");
1755
- const landingUrl = webPreviews.find((p) => p.repo === primaryKey)?.url ?? webPreviews[0]?.url ?? previews.find((p) => p.repo === primaryKey)?.url ?? previews[0]?.url ?? services[0]?.url ?? null;
1756
-
1757
- // ── sign-in: dev.yaml command → saved record → none ──────────────
1758
- const primary = bound?.[0] ?? null;
1759
- const devConfig = primary?.cwd ? readDevConfig(primary.cwd) : null;
1760
- const auth = devConfig?.auth ?? null;
1761
- // Every dev.yaml in the session (external origins, verify policy) — the
1762
- // primary's wins on conflicts.
1763
- const configs = (bound || []).map((b) => (b?.cwd ? readDevConfig(b.cwd) : null)).filter(Boolean);
1764
- const external = [...new Set(configs.flatMap((c) => c.external || []))];
1765
- // The state filter accepts the declared `external` origins too: the
1766
- // browser is allowed to talk to them, so a token the app keeps there is
1767
- // part of "signed in". Cookies stay localhost-only (filterStorageState).
1768
- const stateOrigins = [...new Set([...previewOrigins, ...external])];
1769
- let injected = null; // { source: "command" | "record", state, record?, originMap? }
1770
- if (auth?.storageState && primary?.cwd) {
1771
- const state = await this.storageStateFromCommand(auth.storageState, primary.cwd);
1772
- if (state) injected = { source: "command", state: filterStorageState(state, stateOrigins).state, record: null, originMap: null };
1773
- else this.log("warn", "verify.login.command.empty", { turnId });
1774
- } else if (turn.loginRecordAvailable) {
1775
- const record = await this.api.turnPreviewLogin(turnId).catch((err) => {
1776
- this.log("warn", "verify.login.record.failed", { turnId, error: err?.message });
1777
- return null;
1778
- });
1779
- if (record?.storageState) {
1780
- const originMap = buildOriginMap(record.services, services);
1781
- const state = rewriteStorageState(record.storageState, originMap);
1782
- this.log("info", "verify.login.record", { turnId, version: record.version, mapped: originMap.size, cookies: state.cookies.length, origins: state.origins.length });
1783
- injected = { source: "record", state, record, originMap };
1784
- }
1785
- }
1786
-
1787
- // ── probe: does the preview consider us signed in? ───────────────
1788
- // An API-only preview (no web service) has nothing to render a login
1789
- // wall on — the agent's http_request answers 401/403 → needs_login.
1790
- let probe = { result: "unknown", storageState: null, loginPath: null };
1791
- const hasWebService = services.some((svc) => svc.kind !== "api") || webPreviews.length > 0;
1792
- if (landingUrl && hasWebService) {
1793
- const pw = this.loadPlaywright();
1794
- probe = await this.probeLogin({
1795
- pw,
1796
- launchOptions: launchOptionsFor({ headless: true, browser: channel }),
1797
- storageState: injected?.state ?? null,
1798
- url: injected?.record?.landingUrl ? rewriteUrl(injected.record.landingUrl, injected.originMap) ?? landingUrl : landingUrl,
1799
- previewOrigins,
1800
- loginPaths: injected?.record?.loginPaths ?? [],
1801
- loginCheck: auth?.loginCheck ?? null,
1802
- // A dev server that just booted still compiles its first page (vite's
1803
- // cold transform of a large app takes 30-60 s) — give it room, or the
1804
- // probe answers `unknown` and a login wall goes unnoticed.
1805
- timeoutMs: coldBoot ? PROBE_TIMEOUT_COLD_BOOT_MS : undefined,
1806
- log: this.log,
1807
- });
1808
- }
1809
- // "Continue without signing in" (`previewLoginOptional`) means: run
1810
- // unauthenticated rather than block — whether there is no record at all
1811
- // or the saved one no longer works.
1812
- if (probe.result === "needs_login" && turn.previewLoginOptional === true) {
1813
- this.log("info", "verify.login.optional", { turnId, hadRecord: injected !== null });
1814
- injected = null;
1815
- probe = { result: "unknown", storageState: null, loginPath: probe.loginPath };
1816
- }
1817
- if (probe.result === "needs_login") {
1818
- rmSync(artifactsDir, { recursive: true, force: true });
1819
- if (secretsFile) rmSync(secretsFile, { force: true });
1820
- const firstTime = injected === null;
1821
- return {
1822
- ok: false,
1823
- error: firstTime
1824
- ? "The preview asks for a sign-in. Sign in once on this device so Kai can test the app."
1825
- : "The saved sign-in for this preview no longer works — sign in again on this device.",
1826
- blocked: { blockedCode: "needs_login", ...(probe.loginPath ? { loginPath: probe.loginPath } : {}), firstTime },
1827
- };
1828
- }
1829
-
1830
- // ── state files for the MCP ──────────────────────────────────────
1831
- let storageStateFile = null;
1832
- let finalStateFile = null;
1833
- let redact = new Set();
1834
- let probeExport = null;
1835
- if (injected && !storageStateIsEmpty(injected.state)) {
1836
- const dir = this.loginStateDir();
1837
- mkdirSync(dir, { recursive: true, mode: 0o700 });
1838
- const safe = String(turnId).replace(/[^\w.-]/g, "_");
1839
- storageStateFile = join(dir, `${safe}.json`);
1840
- // The persona's final `browser_storage_state` export must land INSIDE
1841
- // the MCP's `--output-dir`: the Playwright MCP refuses any `filename`
1842
- // outside its output dir / the client cwd (`File access denied …
1843
- // outside allowed roots`), so a path under ~/.kai/state would make the
1844
- // agent's last action fail on every signed-in run and the record would
1845
- // never refresh. A `.json` is not an artifact extension — the sweep
1846
- // never uploads it — and it is read before the evidence dir is deleted.
1847
- finalStateFile = this.finalStateFileFor(artifactsDir);
1848
- mkdirSync(dirname(finalStateFile), { recursive: true, mode: 0o700 });
1849
- // The probe context's own export (with IndexedDB) when the probe ran;
1850
- // the rewritten record otherwise (probe unknown → proceed anyway).
1851
- probeExport = probe.result === "ok" && probe.storageState ? filterStorageState(probe.storageState, stateOrigins).state : injected.state;
1852
- writeFileSync(storageStateFile, JSON.stringify(probeExport), { mode: 0o600 });
1853
- redact = new Set([...redactionSet(injected.state), ...redactionSet(probeExport)]);
1854
- }
1855
- // `.env*` values of the repos + service dirs are secrets too: a tool row
1856
- // that echoes one (a config dump, an error message) must not carry it
1857
- // to the Server.
1858
- for (const value of this.envRedactionValues(bound, services)) redact.add(value);
1859
- const allowedOrigins = [...previewOrigins, ...external, "http://localhost:*", "http://127.0.0.1:*", "https://localhost:*", "https://127.0.0.1:*"];
1860
- // ── API evidence: the verify MCP's request policy ───────────────
1861
- const apiServices = services.filter((svc) => svc.kind === "api");
1862
- const verifyPolicy = configs.find((c) => c.verify)?.verify ?? devConfig?.verify ?? { readOnly: null, endpoints: [], loginVia: null };
1863
- const readOnly = verifyPolicy.readOnly ?? apiServices.length > 0;
1864
- const env = {
1865
- KAI_VERIFY_READ_ONLY: readOnly ? "1" : "0",
1866
- KAI_VERIFY_ORIGINS: [...new Set([...previewOrigins, ...external])].join(";"),
1867
- KAI_VERIFY_EVIDENCE_DIR: artifactsDir,
1868
- };
1869
- // The app's own API credential (dev.yaml `auth.apiToken`) read from the
1870
- // injected sign-in → `Authorization: Bearer …` for http_request. The
1871
- // value goes to the MCP by env only and joins the redaction set.
1872
- const apiAuth = deriveApiAuthHeader(auth?.apiToken, probeExport ?? injected?.state ?? null, services);
1873
- if (apiAuth) {
1874
- env.KAI_VERIFY_AUTH_HEADER = apiAuth.value;
1875
- if (apiAuth.token.length >= 8) redact.add(apiAuth.token);
1876
- }
1877
- return {
1878
- ok: true,
1879
- artifactsDir,
1880
- secretsFile,
1881
- storageStateFile,
1882
- finalStateFile,
1883
- allowedOrigins,
1884
- previewOrigins,
1885
- stateOrigins,
1886
- external,
1887
- // Companions run the code they have checked out — their HEADs travel
1888
- // with the report so "verified" can never be claimed for a stale API.
1889
- companionRevisions: previews
1890
- .filter((p) => p.role === "companion" && p.repo && p.commit)
1891
- .map((p) => ({ repo: p.repo, commit: p.commit, role: "companion", ...(p.branch ? { branch: p.branch } : {}), ...(p.dirty > 0 ? { dirty: p.dirty } : {}) })),
1892
- ignoreHttpsErrors: !!runner?.usesHttps?.(),
1893
- env,
1894
- readOnly,
1895
- injected: injected ? { ...injected, probeExport } : null,
1896
- redact,
1897
- note: this.verifyPreviewNote(sessionId, previews, secretNames, artifactsDir, {
1898
- signedIn: !!storageStateFile,
1899
- finalStateFile,
1900
- services,
1901
- readOnly,
1902
- endpoints: verifyPolicy.endpoints || [],
1903
- external,
1904
- restartNotes,
1905
- apiAuth: !!apiAuth,
1906
- }),
1907
- report: null,
1908
- };
1909
- }
1910
-
1911
- /**
1912
- * After a fix turn (`turn.changedRepos` = repo keys the fix touched):
1913
- * restart the changed repos' services whose reload mode is `restart`
1914
- * (declared, or the default for `kind: api`) on their same ports. Adopted
1915
- * services are the user's own dev server — never restarted, the task
1916
- * note says so. Returns the note lines for the agent.
1917
- */
1918
- async restartChangedServices(turn, sessionId) {
1919
- const changed = Array.isArray(turn.changedRepos) ? turn.changedRepos.map((k) => String(k).toLowerCase()) : [];
1920
- const runner = this.services.get(sessionId);
1921
- if (!changed.length || !runner) return [];
1922
- const notes = [];
1923
- const byRoot = new Map(); // repoRoot → { key, names }
1924
- for (const svc of runner.describeServices()) {
1925
- if (!svc.repoKey || !changed.includes(String(svc.repoKey).toLowerCase()) || !svc.repoRoot) continue;
1926
- const meta = runner.meta?.get(svc.name);
1927
- if (effectiveReload(meta?.svc, svc.kind) !== "restart") continue;
1928
- if (svc.adopted) {
1929
- notes.push(`${svc.name} is your already-running dev server and was not restarted — make sure it serves the fixed code.`);
1930
- continue;
1931
- }
1932
- const entry = byRoot.get(svc.repoRoot) ?? { key: svc.repoKey, names: [] };
1933
- entry.names.push(svc.name);
1934
- byRoot.set(svc.repoRoot, entry);
1935
- }
1936
- for (const [repoRoot, { key, names }] of byRoot) {
1937
- await this.postVerifyStage(turn.turnId, "booting", `Restarting ${names.join(", ")} with the fix…`);
1938
- this.log("info", "verify.restart", { turnId: turn.turnId, repo: key, services: names });
1939
- try {
1940
- const restarted = await runner.restart(repoRoot, { only: names });
1941
- const dead = restarted.find((r) => r.restarted && !r.ready);
1942
- if (dead) notes.push(`${dead.name} did not come back after the restart${dead.error ? ` — ${dead.error}` : ""} (see ${dead.logPath}).`);
1943
- else notes.push(`${names.join(", ")} ${names.length === 1 ? "was" : "were"} restarted with the fix.`);
1944
- } catch (err) {
1945
- this.log("warn", "verify.restart.failed", { turnId: turn.turnId, repo: key, error: err?.message });
1946
- notes.push(`${names.join(", ")} could not be restarted with the fix: ${err?.message}`);
1947
- }
1948
- }
1949
- return notes;
1950
- }
1951
-
1952
- /** Secrets from `.env*` files at the repo roots + service cwds (values ≥ 12 chars, placeholders excluded). */
1953
- envRedactionValues(bound, services) {
1954
- const dirs = new Set([...(bound || []).map((b) => b?.cwd).filter(Boolean), ...(services || []).map((svc) => svc?.cwd).filter(Boolean)]);
1955
- const texts = [];
1956
- for (const dir of dirs) {
1957
- let names = [];
1958
- try {
1959
- names = readdirSync(dir).filter((n) => n === ".env" || (n.startsWith(".env.") && !/\.(example|sample|template)$/.test(n)));
1960
- } catch {
1961
- continue;
1962
- }
1963
- for (const name of names) {
1964
- try {
1965
- texts.push(readFileSync(join(dir, name), "utf8"));
1966
- } catch {
1967
- /* unreadable */
1968
- }
1969
- }
1970
- }
1971
- return envRedactionValues(texts);
1972
- }
1973
-
1974
- /** Task suffix for the verifier: the preview URLs (web + API services), where evidence goes, the secrets by name, the sign-in state, the request policy. */
1975
- verifyPreviewNote(sessionId, previews, secretNames = [], artifactsDir = null, { signedIn = false, finalStateFile = null, services = null, readOnly = false, endpoints = [], external = [], restartNotes = [], apiAuth = false } = {}) {
1976
- const runner = this.services.get(sessionId);
1977
- const apiNames = new Set((services || []).filter((svc) => svc.kind === "api").map((svc) => svc.name));
1978
- const webPreviews = previews.filter((p) => !apiNames.has(p.name));
1979
- const lines = webPreviews.map((p) => {
1980
- const logPath = runner && !p.adopted ? join(runner.logDir, `${p.name}.log`) : null;
1981
- return `- ${p.repo}${p.name && p.name !== p.repo ? ` (${p.name})` : ""}: ${p.url}${p.adopted ? " (your already-running dev server)" : ""}${logPath ? ` · logs: ${logPath}` : ""}`;
1982
- });
1983
- const parts = ["", "", "This is a verification turn: do not modify files, do not commit."];
1984
- if (lines.length > 0) {
1985
- parts.push(`Dev services running for this session:\n${lines.join("\n")}`);
1986
- parts.push(
1987
- "Use the `gleap_preview` browser tools (browser_navigate, browser_snapshot, browser_start_video, browser_video_chapter, browser_take_screenshot, browser_stop_video, …) against these URLs. " +
1988
- "Tail the service logs with the Read/Bash tools if a page fails to load.",
1989
- );
1990
- }
1991
- const apiServices = (services || []).filter((svc) => svc.kind === "api");
1992
- if (apiServices.length > 0) {
1993
- const apiLines = apiServices.map((svc) => {
1994
- const logPath = runner && !svc.adopted ? join(runner.logDir, `${svc.name}.log`) : null;
1995
- return `- ${svc.repoKey ? `${svc.repoKey} (${svc.name})` : svc.name}: ${svc.url}${svc.openapi ? ` · OpenAPI: ${svc.openapi}` : ""}${svc.adopted ? " (your already-running dev server)" : ""}${logPath ? ` · logs: ${logPath}` : ""}`;
1996
- });
1997
- parts.push(
1998
- `API services (no UI):\n${apiLines.join("\n")}\n` +
1999
- `Exercise them with the \`http_request\` tool from the \`kai_verify\` MCP server (one call per check, \`check\` set to what it proves) — the HTTP transcript it records is uploaded with your report as evidence; curl is not available. ` +
2000
- `Resolve real paths from the OpenAPI spec (or the router files) before calling. ` +
2001
- (readOnly
2002
- ? "This run is READ-ONLY (shared database): only GET/HEAD/OPTIONS go through — anything else is refused and recorded as skipped; list such endpoints under `untested`. "
2003
- : "Writes are allowed in this run — prefer creating throwaway records and say what you created in the report. ") +
2004
- (apiAuth ? "The app's own authentication is added to your requests automatically; never paste tokens. " : "A 401/403 means the API needs a sign-in you do not have: report `blocked` / `needs_login` with the endpoint's path, never a failed check. ") +
2005
- (endpoints.length ? `Endpoints the repo asks you to cover: ${endpoints.join(", ")}. ` : "") +
2006
- (lines.length === 0 ? "There is no web UI in this run — no recording is needed; the transcript and the report are the evidence." : ""),
2007
- );
2008
- }
2009
- if (lines.length === 0 && apiServices.length === 0) {
2010
- parts.push(`Dev services running for this session:\n${previews.map((p) => `- ${p.repo}: ${p.url}`).join("\n") || "- (none)"}`);
2011
- }
2012
- if (external.length > 0) parts.push(`External origins the app talks to (allowed for the browser and http_request): ${external.join(", ")}.`);
2013
- if (restartNotes.length > 0) parts.push(restartNotes.join(" "));
2014
- if (artifactsDir) {
2015
- // The MCP resolves a relative `filename` against the CLIENT
2016
- // workspace (the repo worktree), where it would be discarded with
2017
- // the turn — only absolute paths under the output dir (or no
2018
- // filename at all) reach the evidence dir.
2019
- parts.push(
2020
- `Evidence directory: ${artifactsDir} — every screenshot and video in it is uploaded with your report. Pass every screenshot/video \`filename\` as an ABSOLUTE path inside it (e.g. \`${join(artifactsDir, "01-home.png")}\`, \`${join(artifactsDir, "verification.webm")}\`), never a bare relative name, and list the paths the tools print back in your report.`,
2021
- );
2022
- }
2023
- if (secretNames.length > 0) {
2024
- parts.push(
2025
- `Test credentials are available as secrets: ${secretNames.join(", ")}. Type the secret NAME into the form field (e.g. \`${secretNames[0]}\`) — the browser substitutes the real value and masks it in every response. Do not ask the user for these.`,
2026
- );
2027
- }
2028
- if (signedIn && finalStateFile) {
2029
- parts.push(
2030
- `The browser is already signed in to the preview (the user's saved sign-in is loaded). Final browser state path: ${finalStateFile} — as your LAST browser action, after \`browser_stop_video\` and before \`report_verification\`, call \`browser_storage_state\` with exactly this absolute path as \`filename\` so the sign-in stays fresh. ` +
2031
- `Never read, list or copy anything under ${this.loginStateDir()} — it is the daemon's private state. Never paste cookies, tokens or storage values into the report, todos or summary.`,
2032
- );
2033
- } else {
2034
- parts.push(
2035
- "If a page asks you to sign in (password field, one-time code, a Sign in / Log in button, a redirect to an identity provider) and no secrets are listed, do not ask the user and do not type into the form: stop the video, screenshot the wall, and file `report_verification` with `status: blocked`, `blockedCode: needs_login` and `loginPath` set to the wall's URL path.",
2036
- );
2037
- }
2038
- parts.push("If the change has nothing a tester can exercise from the running app, file `report_verification` with `status: blocked` and `blockedCode: not_verifiable` — never invent a check. A `passed` report needs at least one real check.");
2039
- return parts.join("\n\n").replace(/^\n\n\n\n/, "\n\n");
2040
- }
2041
-
2042
- /**
2043
- * After a verify turn: sweep the evidence dir (the agent may have
2044
- * forgotten the recording), upload every file, then post the report
2045
- * with URLs in place of paths (redacted: nothing from the injected
2046
- * sign-in may leak). No report from the agent → `blocked` with the swept
2047
- * evidence; a report with zero checks → `blocked` / `no_report` (the
2048
- * payload builder). A turn CANCELLED before any report skips the uploads
2049
- * altogether (nobody asked for that footage). `evidence: { failed, kept }`
2050
- * tells the Server how many uploads failed and where the files still are
2051
- * (`bridge.verify.evidence.retry` re-uploads them). Then, when a saved
2052
- * sign-in was injected and the agent left its final `browser_storage_state`
2053
- * export, refresh the record (CAS on the version we read). The dir is
2054
- * deleted only when everything landed; otherwise it stays for the retry /
2055
- * `kai-bridge doctor`.
2056
- */
2057
- async finishVerification(turn, verify, res, cancelled) {
2058
- const { turnId } = turn;
2059
- await this.postVerifyStage(turnId, "saving");
2060
- const redact = verify.redact?.size ? (v) => redactDeep(v, verify.redact) : (v) => v;
2061
- const report = verify.report ? redact(verify.report) : null;
2062
- // Verify turns never push, so the Server cannot derive the tested heads
2063
- // from `changes[]` — stamp them here (repo key → HEAD of the checkout).
2064
- // Companions booted alongside carry `role: "companion"` (their HEAD is
2065
- // the code the API under test actually served).
2066
- const revisions = [
2067
- ...(verify.bound || [])
2068
- .map((b) => ({ repo: b.key, commit: currentHead(b.cwd) }))
2069
- .filter((r) => r.repo && r.commit),
2070
- ...(verify.companionRevisions || []),
2071
- ];
2072
- const fallbackReason = cancelled
2073
- ? "The verification was cancelled before Kai filed a report."
2074
- : res.code !== 0
2075
- ? `The verification turn failed${res.lastError ? `: ${res.lastError.replace(/^acp-runner: /, "").slice(0, 300)}` : ""}.`
2076
- : "Kai finished without filing a verification report";
2077
- let uploads = [];
2078
- let uploadFailures = 0;
2079
- if (cancelled && !report) {
2080
- this.log("info", "verify.cancelled.no_uploads", { turnId });
2081
- } else {
2082
- const swept = await this.uploadSweptEvidence(turnId, verify.artifactsDir, { report, redact: verify.redact, workDir: verify.workDir, signedIn: !!verify.storageStateFile });
2083
- uploads = swept.uploads;
2084
- uploadFailures = swept.failed;
2085
- }
2086
- const evidence = { failed: uploadFailures, ...(uploadFailures > 0 ? { kept: verify.artifactsDir } : {}) };
2087
- const payload = redact({ ...buildVerificationPayload(report, uploads, { fallbackReason, cancelled, evidence, readOnly: typeof verify.readOnly === "boolean" ? verify.readOnly : null }), revisions });
2088
- let reported = false;
2089
- try {
2090
- await this.api.turnVerification(turnId, payload, {
2091
- onRetry: (err, attempt, delay) => this.log("warn", "verify.report.retry", { turnId, attempt, nextInMs: delay, error: err.message }),
2092
- });
2093
- reported = true;
2094
- } catch (err) {
2095
- this.log("error", "verify.report.failed", { turnId, error: err.message });
2096
- }
2097
- this.log("info", "verify.reported", { turnId, status: payload.status, blockedCode: payload.blockedCode, checks: payload.checks.length, artifacts: uploads.length, uploadFailures, reported });
2098
- // The final-state export lives in the evidence dir: refresh the record
2099
- // from it BEFORE the dir is deleted.
2100
- await this.refreshPreviewLogin(turn, verify);
2101
- if ((reported && uploadFailures === 0) || (cancelled && !report)) rmSync(verify.artifactsDir, { recursive: true, force: true });
2102
- else this.log("warn", "verify.artifacts.kept", { turnId, dir: verify.artifactsDir });
2103
- return { status: payload.status };
2104
- }
2105
-
2106
- /**
2107
- * A preview that Verify booted for itself is released once the run has
2108
- * settled — a manual preview keeps running until the user stops it. A
2109
- * `failed` run with fix attempts left keeps the app up: the fix turn's
2110
- * auto re-verify is seconds away and the Server restarts changed services.
2111
- */
2112
- async settleVerifyPreview(turn, status) {
2113
- const sessionId = turn?.sessionId;
2114
- if (!sessionId || !this.verifyOwnedPreviews?.has(sessionId)) return false;
2115
- if (this.manualPreviews?.has(sessionId)) return false;
2116
- const loop = turn.loop && typeof turn.loop === "object" ? turn.loop : null;
2117
- const retryPending = status === "failed" && loop && Number(loop.attempt ?? 0) < Number(loop.max ?? 0);
2118
- if (retryPending) return false;
2119
- this.log("info", "verify.preview.released", { sessionId, status });
2120
- await this.previewStop({ sessionId });
2121
- return true;
2122
- }
2123
-
2124
- /**
2125
- * Sweep an evidence dir, union it with the report's declared artifacts,
2126
- * redact the HTTP transcript in place, upload everything. Resolves
2127
- * `{ uploads: [{ artifact, url }], failed }`. Trace archives are dropped
2128
- * from signed-in runs (they contain every request with its cookies).
2129
- */
2130
- async uploadSweptEvidence(turnId, artifactsDir, { report = null, redact = null, workDir = null, signedIn = false } = {}) {
2131
- const swept = sweepArtifacts(artifactsDir).filter((p) => !(signedIn && /\.zip$/i.test(p)));
2132
- const artifacts = unionArtifacts(report, swept, { outputDir: artifactsDir, workDir }).filter((a) => !(signedIn && a.kind === "trace"));
2133
- const uploads = [];
2134
- let failed = 0;
2135
- for (const artifact of artifacts) {
2136
- if (artifact.kind === "requests") redactJsonlFile(artifact.path, redact);
2137
- try {
2138
- const { url } = await this.api.uploadArtifact(turnId, artifact.path, artifact.contentType, {
2139
- onRetry: (err, attempt, delay) => this.log("warn", "verify.upload.retry", { turnId, file: artifact.path, attempt, nextInMs: delay, error: err.message }),
2140
- });
2141
- uploads.push({ artifact, url });
2142
- } catch (err) {
2143
- failed += 1;
2144
- this.log("warn", "verify.upload.failed", { turnId, file: artifact.path, error: err.message });
2145
- }
2146
- }
2147
- return { uploads, failed, swept: swept.length };
2148
- }
2149
-
2150
- /**
2151
- * `bridge.verify.evidence.retry { turnId }`: the evidence dir kept after
2152
- * failed uploads is swept and uploaded again; the Server unions the
2153
- * artifacts by URL (`PUT …/verification/artifacts`) and clears
2154
- * `evidenceMissing`. The dir goes once everything landed.
2155
- */
2156
- async retryEvidence({ commandId, turnId }) {
2157
- const ack = (payload) => (commandId ? this.api.commandAck(commandId, payload).catch((err) => this.log("warn", "verify.evidence.ack.failed", { turnId, error: err?.message })) : Promise.resolve());
2158
- if (!turnId) return ack({ ok: false, error: "evidence retry without a turnId" });
2159
- const dir = join(this.kaiHome, "artifacts", String(turnId).replace(/[^\w.-]/g, "_"));
2160
- if (!existsSync(dir)) {
2161
- this.log("warn", "verify.evidence.retry.missing", { turnId, dir });
2162
- return ack({ ok: false, error: "The evidence for this run is no longer on this machine." });
2163
- }
2164
- const { uploads, failed, swept } = await this.uploadSweptEvidence(turnId, dir);
2165
- const artifacts = uploads.map((u) => ({ label: u.artifact.label, url: u.url, contentType: u.artifact.contentType, kind: u.artifact.kind, ...(u.artifact.count > 0 ? { count: u.artifact.count } : {}) }));
2166
- const evidence = { failed, ...(failed > 0 ? { kept: dir } : {}) };
2167
- try {
2168
- await this.api.putVerificationArtifacts(turnId, { artifacts, evidence });
2169
- } catch (err) {
2170
- this.log("error", "verify.evidence.retry.failed", { turnId, error: err?.message });
2171
- return ack({ ok: false, error: err?.message || String(err), uploaded: artifacts.length, failed });
2172
- }
2173
- this.log("info", "verify.evidence.retried", { turnId, swept, uploaded: artifacts.length, failed });
2174
- if (failed === 0) rmSync(dir, { recursive: true, force: true });
2175
- return ack({ ok: true, uploaded: artifacts.length, failed });
2176
- }
2177
-
2178
- /**
2179
- * The agent's final `browser_storage_state` export (cookies + localStorage
2180
- * — what the app may have rotated during the run) merged over the probe
2181
- * export's IndexedDB, filtered to the preview origins, mapped BACK onto
2182
- * the record's captured origins, and PUT with the version we read. 409 =
2183
- * another run refreshed first → ours is discarded. Only for injected
2184
- * records (a dev.yaml command owns its own state).
2185
- */
2186
- async refreshPreviewLogin(turn, verify) {
2187
- const { turnId } = turn;
2188
- const record = verify.injected?.record;
2189
- if (!record?.id || verify.injected.source !== "record" || !verify.finalStateFile) return;
2190
- let final;
2191
- try {
2192
- if (!existsSync(verify.finalStateFile)) {
2193
- this.log("info", "verify.login.refresh.skipped", { turnId, reason: "no final state" });
2194
- return;
2195
- }
2196
- final = JSON.parse(readFileSync(verify.finalStateFile, "utf8"));
2197
- } catch (err) {
2198
- this.log("warn", "verify.login.refresh.unreadable", { turnId, error: err?.message });
2199
- return;
2200
- }
2201
- const merged = mergeFinalState(verify.injected.probeExport, final);
2202
- const { state: filtered, counts } = filterStorageState(merged, verify.stateOrigins ?? verify.previewOrigins);
2203
- const back = rewriteStorageState(filtered, invertOriginMap(verify.injected.originMap));
2204
- if (storageStateIsEmpty(back)) {
2205
- this.log("info", "verify.login.refresh.skipped", { turnId, reason: "empty state" });
2206
- return;
2207
- }
2208
- try {
2209
- await this.api.refreshPreviewLogin(record.id, { storageState: back, basedOnVersion: record.version });
2210
- this.log("info", "verify.login.refreshed", { turnId, recordId: record.id, basedOnVersion: record.version, ...counts });
2211
- } catch (err) {
2212
- this.log(err?.status === 409 ? "info" : "warn", err?.status === 409 ? "verify.login.refresh.stale" : "verify.login.refresh.failed", { turnId, recordId: record.id, error: err?.message });
2213
- }
2214
- }
2215
-
2216
- // ── sign-in windows (bridge.verify.login.*) ────────────────────────
2217
- /**
2218
- * Open a headed Chrome at the preview for the user to sign in; the
2219
- * capture uploads the filtered storage state against `requestId`. Acks
2220
- * `{ ok: true, opened: true }` once the window is up, `{ ok: false,
2221
- * error }` when this machine cannot (updating, no display, preview dead).
2222
- * Idempotent per requestId (the Server replays after sleep).
2223
- */
2224
- async loginStart({ commandId, requestId, sessionId, repoKey, previewNeeded, title, repos }) {
2225
- const ack = (payload) => (commandId ? this.api.commandAck(commandId, payload).catch((err) => this.log("warn", "verify.login.ack.failed", { requestId, error: err?.message })) : Promise.resolve());
2226
- const status = (payload) => this.api.previewLoginStatus(requestId, payload).catch((err) => this.log("warn", "verify.login.status.failed", { requestId, error: err?.message }));
2227
- if (!requestId) {
2228
- await ack({ ok: false, error: "Sign-in request without a requestId." });
2229
- return;
2230
- }
2231
- if (this.logins.has(requestId)) {
2232
- await ack({ ok: true, opened: true, replayed: true });
2233
- return;
2234
- }
2235
- const entry = { requestId, sessionId, repoKey, handle: null, startedAt: Date.now() };
2236
- this.logins.set(requestId, entry);
2237
- try {
2238
- if (this.updating) throw new Error("This machine is updating kai-bridge — try again in a minute.");
2239
- if (!this.displayAvailable()) throw new Error("This machine has no display to open a sign-in window on.");
2240
- const pw = this.loadPlaywright();
2241
- if (!pw?.chromium) throw new Error("Playwright is not installed next to kai-bridge — reinstall @gleapai/kai-bridge.");
2242
- const browser = await this.ensurePreviewBrowser().catch((err) => ({ ok: false, error: err?.message }));
2243
- if (!browser?.ok) throw new Error(browser?.error || "No browser is available on this machine.");
2244
- // Liveness first: booting a cold preview can take minutes, and the
2245
- // Server fails a request nobody acknowledged as "device unreachable".
2246
- // `accepted` moves the request off pending without claiming a window.
2247
- await ack({ ok: true, accepted: true });
2248
- // The preview must be up for the user to sign in to — boot it (or,
2249
- // when it already runs, only re-describe it: previewStart is
2250
- // idempotent on a live runner and we need its URLs either way).
2251
- // `repos`/`title` may be absent on the command; then the request's
2252
- // repo is the whole session and the worktree is looked up by id.
2253
- const sessionRepos = Array.isArray(repos) && repos.length ? repos : [{ key: repoKey }];
2254
- this.log("info", "verify.login.preview", { requestId, sessionId, previewNeeded: !!previewNeeded, live: this.services.has(sessionId) });
2255
- const preview = await this.previewStart({ sessionId, title, repos: sessionRepos });
2256
- if (preview?.status !== "running") throw new Error(preview?.error ? `The preview could not be started: ${preview.error}` : "The preview could not be started.");
2257
- entry.previews = preview.previews || [];
2258
- const runner = this.services.get(sessionId);
2259
- const services = runner?.describeServices?.() ?? (entry.previews || []).map((p) => ({ name: p.name, url: p.url }));
2260
- const previewOrigins = [...new Set([...services.map((s) => normalizeOrigin(s.url)), ...(entry.previews || []).map((p) => normalizeOrigin(p.url))].filter(Boolean))];
2261
- // dev.yaml `external` origins may hold part of the app's own sign-in
2262
- // state — keepable, but never "back on the app" for the detection.
2263
- const external = [...new Set([...new Set((services || []).map((s) => s.repoRoot).filter(Boolean))].flatMap((root) => readDevConfig(root)?.external || []))];
2264
- const url = (entry.previews || []).find((p) => p.repo === repoKey)?.url ?? (entry.previews || [])[0]?.url ?? services[0]?.url;
2265
- if (!url) throw new Error("The preview has no URL to open.");
2266
- // Keep the preview alive while the window is open (the idle timer
2267
- // would otherwise stop the app under the user's nose).
2268
- this.armPreviewIdleTimer(sessionId);
2269
- const handle = await this.captureLogin({
2270
- pw,
2271
- launchOptions: launchOptionsFor({ headless: false, browser: browser.browser === "chrome" ? "chrome" : null }),
2272
- url,
2273
- previewOrigins,
2274
- stateOrigins: [...previewOrigins, ...external],
2275
- services: services.map((s) => ({ name: s.name, origin: normalizeOrigin(s.url) })),
2276
- onStatus: (st, extra) => {
2277
- this.log("info", "verify.login.status", { requestId, status: st, ...(extra?.error ? { error: extra.error } : {}) });
2278
- return status({ status: st, ...(extra?.error ? { error: String(extra.error) } : {}) });
2279
- },
2280
- onSaved: async (payload, counts) => {
2281
- await this.api.uploadPreviewLogin(requestId, payload, {
2282
- tries: 3,
2283
- onRetry: (err, attempt, delay) => this.log("warn", "verify.login.upload.retry", { requestId, attempt, nextInMs: delay, error: err.message }),
2284
- });
2285
- this.log("info", "verify.login.saved", { requestId, repoKey, ...counts, loginPaths: payload.loginPaths.length });
2286
- },
2287
- log: this.log,
2288
- });
2289
- void handle.finished.then((outcome) => {
2290
- this.logins.delete(requestId);
2291
- this.log("info", "verify.login.finished", { requestId, status: outcome?.status, error: outcome?.error });
2292
- this.hello().catch(() => {});
2293
- if ((this.updatePending || this.restartPending) && this.running.size === 0 && this.logins.size === 0) void this.checkForUpdate();
2294
- });
2295
- if (entry.cancelled) {
2296
- // Cancelled while Chrome was still launching (the Server has already
2297
- // settled the request): close the window it just opened, no ack of
2298
- // `opened`, no status (the cancel already answered).
2299
- this.log("info", "verify.login.cancelled.launching", { requestId });
2300
- await handle.cancel();
2301
- await ack({ ok: false, error: "The sign-in was cancelled before the window opened." });
2302
- return;
2303
- }
2304
- entry.handle = handle;
2305
- await ack({ ok: true, opened: true });
2306
- await status({ status: "opened" });
2307
- // Re-announce right away: `activeLogins` is how the dashboard knows a window is open.
2308
- await this.hello().catch((err) => this.log("warn", "verify.login.hello.failed", { requestId, error: err?.message }));
2309
- } catch (err) {
2310
- this.logins.delete(requestId);
2311
- this.log("error", "verify.login.start.failed", { requestId, error: err?.message });
2312
- await ack({ ok: false, error: err?.message || String(err) });
2313
- await status({ status: "failed", error: err?.message || String(err) });
2314
- }
2315
- }
2316
-
2317
- /** "Mark done": export whatever the window holds now (the heuristic may have missed the sign-in). */
2318
- async loginDone({ commandId, requestId }) {
2319
- try {
2320
- const entry = this.logins.get(requestId);
2321
- if (!entry?.handle) throw new Error("No sign-in window is open for this request on this machine.");
2322
- await entry.handle.done();
2323
- if (commandId) await this.api.commandAck(commandId, { ok: true });
2324
- } catch (err) {
2325
- this.log("warn", "verify.login.done.failed", { requestId, error: err?.message });
2326
- if (commandId) await this.api.commandAck(commandId, { ok: false, error: err?.message || String(err) }).catch(() => {});
2327
- }
2328
- }
2329
-
2330
- async loginCancel({ commandId, requestId }) {
2331
- try {
2332
- const entry = this.logins.get(requestId);
2333
- if (!entry) throw new Error("No sign-in window is open for this request on this machine.");
2334
- if (entry.handle) await entry.handle.cancel();
2335
- // Still launching: loginStart closes the window as soon as it is up.
2336
- else entry.cancelled = true;
2337
- if (commandId) await this.api.commandAck(commandId, { ok: true });
2338
- } catch (err) {
2339
- this.log("warn", "verify.login.cancel.failed", { requestId, error: err?.message });
2340
- if (commandId) await this.api.commandAck(commandId, { ok: false, error: err?.message || String(err) }).catch(() => {});
2341
- }
2342
- }
2343
-
2344
- /** Close every sign-in window matching `filter` (session close, shutdown). */
2345
- async teardownLogins(filter, reason) {
2346
- if (!this.logins?.size) return;
2347
- const victims = [...this.logins.values()].filter((l) => {
2348
- try {
2349
- return filter(l);
2350
- } catch {
2351
- return false;
2352
- }
2353
- });
2354
- for (const l of victims) {
2355
- this.log("info", "verify.login.teardown", { requestId: l.requestId, reason });
2356
- this.logins.delete(l.requestId);
2357
- l.cancelled = true; // still launching → loginStart closes it on arrival
2358
- await l.handle?.cancel?.().catch(() => {});
2359
- }
2360
- }
2361
-
2362
1880
  /**
2363
1881
  * Mid-turn steering: forward the message to the running runner's stdin
2364
1882
  * control channel. The runner answers with a `steer` turn event
@@ -2604,9 +2122,44 @@ export function defaultRealtimeFactory({ config, channel, onEvent, onState }) {
2604
2122
  });
2605
2123
  client.connection?.bind?.("state_change", (s) => onState(s.current));
2606
2124
  const ch = client.subscribe(channel);
2607
- ch.bind_global?.((name, data) => {
2608
- if (typeof name === "string" && name.startsWith("bridge.")) onEvent(name, data);
2609
- });
2125
+ ch.bind_global?.((name, data) => routeRealtimeEvent(name, data, { onEvent, onState }));
2610
2126
  })().catch((err) => onState(`error:${err.message}`));
2611
2127
  return { disconnect: () => client?.disconnect?.() };
2612
2128
  }
2129
+
2130
+ /**
2131
+ * One channel event → command or state. `pusher:subscription_error` is the
2132
+ * case that used to slip through: the socket is up, so the connection
2133
+ * reports `connected`, but the private-channel auth failed (the API was
2134
+ * restarting) and nothing is subscribed — the daemon sat "online" and deaf
2135
+ * until the next socket drop. Routed as a state, handleRealtimeState
2136
+ * reconnects from scratch like any other terminal state.
2137
+ */
2138
+ export function routeRealtimeEvent(name, data, { onEvent, onState }) {
2139
+ if (typeof name !== "string") return;
2140
+ if (name === "pusher:subscription_error") {
2141
+ const detail = [data?.status, data?.error].filter((v) => v !== undefined && v !== null && v !== "").join(" ");
2142
+ onState(`subscription_error${detail ? `:${detail}` : ""}`);
2143
+ return;
2144
+ }
2145
+ if (name.startsWith("bridge.")) onEvent(name, data);
2146
+ }
2147
+
2148
+ /**
2149
+ * A probe that could not run (the CLI timed out, could not be spawned)
2150
+ * answers `unknown`; announcing that would flip the dashboard to
2151
+ * "signed out" until the next good probe. Carry the last definite state
2152
+ * for that profile instead, and remember every definite answer.
2153
+ */
2154
+ export function carryAuthStates(profiles, lastAuthStates) {
2155
+ for (const p of profiles) {
2156
+ if (!p?.id) continue;
2157
+ if (p.authState === "unknown") {
2158
+ const last = lastAuthStates.get(p.id);
2159
+ if (last) Object.assign(p, last);
2160
+ } else if (p.authState) {
2161
+ lastAuthStates.set(p.id, { authState: p.authState, account: p.account ?? null });
2162
+ }
2163
+ }
2164
+ return profiles;
2165
+ }