@gleapai/kai-bridge 0.9.0 → 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,76 +12,51 @@ 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";
43
+ import { GIT_AUTH_TTL_MS, gitAuthEnv, isGitAuthError } from "./git-auth.mjs";
57
44
  import { describeHarnesses, installHarness, probeHarnessAuth } from "./harnesses.mjs";
58
45
  import { probeHarnessModels } from "./models.mjs";
59
46
  import { decideRestart, decideUpdate, fetchLatestVersion, installVersion, installedVersion, installedVersionOrNull, isNewer } from "./selfupdate.mjs";
60
47
  import { dirname } from "node:path";
61
48
  import { fileURLToPath } from "node:url";
62
49
 
63
- const PROBE_TIMEOUT_COLD_BOOT_MS = 60_000;
64
50
  const CLONE_TIMEOUT_MS = 3 * 60_000;
65
51
  const COMPANION_MAX_DEPTH = 3;
66
52
  const RUNNER_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "runner");
67
53
 
68
- /** A captured landing URL moved onto the current origin map (null when its origin is not running). */
69
- function rewriteUrl(url, originMap) {
70
- const origin = normalizeOrigin(url);
71
- const to = origin && originMap ? originMap.get(origin) : null;
72
- if (!to) return null;
73
- try {
74
- const u = new URL(String(url));
75
- return `${to}${u.pathname}${u.search}${u.hash}`;
76
- } catch {
77
- return null;
78
- }
79
- }
80
-
81
54
  /** Lock paths held by daemons in THIS process (cross-process uses the file). */
82
55
  const HELD_LOCKS = new Set();
83
56
  const REALTIME_RETRY_MS = 15_000;
84
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;
85
60
  const USAGE_REFRESH_MS = 10 * 60_000;
86
61
  // Harness model catalogues change on releases, not by the minute.
87
62
  const MODELS_REFRESH_MS = 6 * 60 * 60_000;
@@ -158,6 +133,8 @@ export class BridgeDaemon {
158
133
  this.kaiHome = kaiHome;
159
134
  this.log = log;
160
135
  this.api = new BridgeApi({ apiBase: config.apiBase, token: config.device?.token });
136
+ /** repoKey → { authHeader, at }: Server-issued git credentials (see git-auth.mjs). */
137
+ this.gitAuth = new Map();
161
138
  this.realtimeFactory = realtimeFactory;
162
139
  this.running = new Map(); // turnId → { ctrl: AbortController, control?: (obj) => boolean }
163
140
  this.services = new Map(); // sessionId → ServiceRunner (lives across turns)
@@ -168,9 +145,6 @@ export class BridgeDaemon {
168
145
  this.updateAvailable = null; // newer registry version, when one exists
169
146
  this.updateError = null; // why the last self-update did not apply
170
147
  this.updating = false; // npm is replacing our files — refuse new turns
171
- this.logins = new Map(); // requestId → { sessionId, repoKey, handle } — open sign-in windows (preview-login.mjs)
172
- this.verifyLocks = new Map(); // `${userId}:${repoKey}` → promise chain (one probe/refresh of a sign-in at a time)
173
- this.previewStatusListeners = new Map(); // sessionId → (message) => void — the booting heartbeat of a verify turn
174
148
  this.unknownDevKeysLogged = new Set(); // "<repo>:<key>" — unknown dev.yaml keys are logged once
175
149
  this.restartPending = false; // files on disk are a different version than the one we loaded; restart when idle
176
150
  }
@@ -184,7 +158,7 @@ export class BridgeDaemon {
184
158
  }
185
159
 
186
160
  async hello() {
187
- 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()));
188
162
  const repos = toDeviceRepoReport(this.repoGroups);
189
163
  return this.api.hello({
190
164
  name: this.config.device?.name,
@@ -198,10 +172,6 @@ export class BridgeDaemon {
198
172
  updateAvailable: this.updateAvailable || null,
199
173
  updateError: this.updateError || null,
200
174
  autoUpdate: this.config.autoUpdate !== false,
201
- // Verify sign-in: can this machine open a headed Chrome window, and
202
- // which sign-in requests are open right now (replayed after sleep).
203
- capabilities: { display: this.displayAvailable() },
204
- activeLogins: [...this.logins.keys()],
205
175
  });
206
176
  }
207
177
 
@@ -216,16 +186,29 @@ export class BridgeDaemon {
216
186
  // (postinstall applies the patch; a self-update or --ignore-scripts
217
187
  // install can leave it unpatched). Idempotent, best-effort.
218
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) });
219
192
  // A previous run that was killed (reboot, crash, `kill -9`) never got
220
193
  // to report its turns. Tell the server before doing anything else,
221
194
  // so those sessions settle instead of spinning.
222
195
  await this.reportInterruptedTurns();
223
196
  // Dev servers a killed daemon left behind would hold the declared ports
224
- // (→ port_busy on the next preview) — end them; then drop evidence dirs
225
- // 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 }));
226
211
  this.killOrphanedServices();
227
- const swept = sweepOldArtifacts(join(this.kaiHome, "artifacts"), { maxAgeMs: ARTIFACT_MAX_AGE_MS });
228
- if (swept.length) this.log("info", "artifacts.swept", { count: swept.length });
229
212
  await this.scanRepos();
230
213
  // The first hello must not kill the daemon: the server may be
231
214
  // restarting (deploys, local nodemon) — retry with backoff instead
@@ -233,6 +216,7 @@ export class BridgeDaemon {
233
216
  for (let delay = 5_000; ; delay = Math.min(delay * 2, 60_000)) {
234
217
  try {
235
218
  await this.hello();
219
+ await this.reportResumedPreviews();
236
220
  break;
237
221
  } catch (err) {
238
222
  if (err?.status === 401 || err?.status === 403) {
@@ -260,10 +244,20 @@ export class BridgeDaemon {
260
244
  // behind, node exited 0, and launchd's SuccessfulExit:false meant the
261
245
  // machine stayed offline until the next login — silently.
262
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.
263
249
  this.api
264
- .heartbeat({ running: [...this.running.keys()] })
250
+ .heartbeat({ running: [...this.running.keys()], previews: [...this.services.keys()] })
251
+ .then(() => this.flushPendingReports())
265
252
  .catch((err) => this.onApiError("heartbeat", err));
266
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?.();
267
261
  // Plan-usage windows for the composer popover. Fire-and-forget on a
268
262
  // slow cadence, unref'd (the heartbeat keeps the process alive), and
269
263
  // NEVER in hello's path — the probe spawns the CLI (~2s per profile).
@@ -296,7 +290,7 @@ export class BridgeDaemon {
296
290
  // into them as soon as no turn is running — BEFORE asking the registry,
297
291
  // which needs network this check must not depend on.
298
292
  const onDisk = installedVersionOrNull();
299
- 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 });
300
294
  this.restartPending = restart.action === "defer";
301
295
  if (restart.action === "restart") {
302
296
  this.log("info", "update.restart.stale", { loaded: VERSION, installed: onDisk });
@@ -314,10 +308,9 @@ export class BridgeDaemon {
314
308
  const decision = decideUpdate({
315
309
  current: VERSION,
316
310
  latest,
317
- // An open sign-in window is work in flight too: replacing our files
318
- // under a headed Chrome the user is typing into loses the capture —
319
- // and so is a live preview someone may be looking at right now.
320
- 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,
321
314
  autoUpdate: this.config.autoUpdate !== false,
322
315
  lastAttempt: this.config.selfUpdate?.lastAttempt ?? null,
323
316
  });
@@ -396,7 +389,11 @@ export class BridgeDaemon {
396
389
  const candidates = profiles
397
390
  .filter((p) => p.harness === harness && p.kind !== "gleap-key" && p.configDir)
398
391
  .sort((a, b) => Number(b.kind === "ambient") - Number(a.kind === "ambient"));
399
- 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;
400
397
  let models = null;
401
398
  if (profile) {
402
399
  try {
@@ -450,27 +447,35 @@ export class BridgeDaemon {
450
447
  /**
451
448
  * Shut down: abort turns, stop every preview (telling the dashboard why —
452
449
  * `stopped` / `daemon_restarted`, so the card offers Start instead of
453
- * showing dead links), close sign-in windows. Resolves once the preview
450
+ * showing dead links). Resolves once the preview
454
451
  * reports have been given a bounded chance to land (the self-update
455
452
  * restart awaits it; sync callers may ignore the promise).
456
453
  */
457
454
  stop() {
458
455
  this.stopped = true;
459
456
  clearInterval(this.heartbeat);
457
+ clearInterval(this.pendingPoll);
460
458
  clearInterval(this.usageTimer);
461
459
  clearInterval(this.modelsTimer);
462
460
  clearInterval(this.updateTimer);
463
461
  if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
464
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.
465
468
  const reports = [];
466
469
  for (const [sessionId, runner] of this.services) {
467
- 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();
468
475
  this.clearPreviewIdleTimer(sessionId);
469
- reports.push(this.api.sessionPreview(sessionId, { status: "stopped", urls: [], previews: [], errorCode: "daemon_restarted", error: "kai-bridge restarted — start the preview again." }).catch(() => {}));
470
476
  }
471
477
  this.services.clear();
472
- this.writePreviewPids([]);
473
- void this.teardownLogins(() => true, "daemon stopping");
478
+ for (const w of this.prewarming?.values() ?? []) void w.close();
474
479
  this.realtime?.disconnect?.();
475
480
  this.releaseLock();
476
481
  return Promise.race([Promise.allSettled(reports), new Promise((r) => setTimeout(r, 3_000).unref?.())]).then(() => undefined);
@@ -528,7 +533,7 @@ export class BridgeDaemon {
528
533
  return join(this.kaiHome, "state", "inflight.json");
529
534
  }
530
535
 
531
- /** `[{ turnId, sessionId?, agent?, artifactsDir? }]` — pre-0.5.0 files held bare ids. */
536
+ /** `[{ turnId, sessionId?, agent? }]` — pre-0.5.0 files held bare ids. */
532
537
  readInflight() {
533
538
  try {
534
539
  const raw = JSON.parse(readFileSync(this.inflightPath, "utf8"));
@@ -547,21 +552,18 @@ export class BridgeDaemon {
547
552
  }
548
553
  }
549
554
 
555
+ /** Merges: the runner pid and the verify artifacts dir arrive after the turn's session meta. */
550
556
  rememberInflight(turnId, meta = {}) {
551
- const rest = this.readInflight().filter((e) => e.turnId !== turnId);
552
- this.writeInflight([...rest, { turnId, ...meta }]);
557
+ const all = this.readInflight();
558
+ const existing = all.find((e) => e.turnId === turnId);
559
+ this.writeInflight([...all.filter((e) => e.turnId !== turnId), { ...existing, turnId, ...meta }]);
553
560
  }
554
561
 
555
562
  forgetInflight(turnId) {
556
563
  this.writeInflight(this.readInflight().filter((e) => e.turnId !== turnId));
557
564
  }
558
565
 
559
- /**
560
- * Turns this machine was running when it was killed — report them dead.
561
- * A verify turn also gets a `blocked` report ("kai-bridge restarted
562
- * mid-run") carrying whatever evidence the browser had written, so the
563
- * card shows the partial recording instead of spinning.
564
- */
566
+ /** Turns this machine was running when it was killed — report them dead. */
565
567
  async reportInterruptedTurns() {
566
568
  const entries = this.readInflight();
567
569
  if (!entries.length) return;
@@ -569,16 +571,6 @@ export class BridgeDaemon {
569
571
  for (const entry of entries) {
570
572
  const { turnId } = entry;
571
573
  this.log("warn", "turn.interrupted", { turnId, agent: entry.agent });
572
- if (entry.agent === "kai-verifier") {
573
- const uploads = entry.artifactsDir ? await this.uploadSweptEvidence(turnId, entry.artifactsDir) : { uploads: [], failed: 0 };
574
- await this.api
575
- .turnVerification(turnId, {
576
- ...buildVerificationPayload(null, uploads.uploads, { fallbackReason: "kai-bridge restarted mid-run", evidence: { failed: uploads.failed, ...(uploads.failed && entry.artifactsDir ? { kept: entry.artifactsDir } : {}) } }),
577
- blockedCode: "other",
578
- })
579
- .catch((err) => this.log("warn", "verify.interrupted.report.failed", { turnId, error: err?.message }));
580
- if (entry.artifactsDir && uploads.failed === 0) rmSync(entry.artifactsDir, { recursive: true, force: true });
581
- }
582
574
  await this.api
583
575
  .turnResult(turnId, {
584
576
  status: "failed",
@@ -611,11 +603,155 @@ export class BridgeDaemon {
611
603
  }
612
604
  }
613
605
 
614
- /** ServiceRunner hook: every dev-server pid is persisted while it lives. */
615
- trackServicePid(sessionId, name, pid, op) {
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
+
727
+ /**
728
+ * ServiceRunner hook: every dev-server pid is persisted while it lives —
729
+ * with its port and the session's title/link, so `kai-bridge ps` can
730
+ * show a preview-only session without a turn to borrow the meta from.
731
+ */
732
+ trackServicePid(sessionId, name, pid, op, { port = null } = {}) {
616
733
  if (!Number.isInteger(pid)) return;
617
734
  const rest = this.readPreviewPids().filter((e) => e.pid !== pid);
618
- this.writePreviewPids(op === "add" ? [...rest, { pid, name, sessionId, startedAt: new Date().toISOString() }] : rest);
735
+ const session = this.sessionMeta?.get(sessionId) ?? {};
736
+ this.writePreviewPids(op === "add" ? [...rest, { pid, name, sessionId, port, ...session, startedAt: new Date().toISOString() }] : rest);
737
+ }
738
+
739
+ /**
740
+ * Title, dashboard link and repo keys of a session this machine works
741
+ * on — copied into the inflight / preview-pid entries (ps.mjs reads
742
+ * them back). Remembered from turn starts and preview starts alike.
743
+ */
744
+ rememberSession(sessionId, { title = null, sessionUrl = null, repos = [] } = {}) {
745
+ if (typeof sessionId !== "string") return {};
746
+ this.sessionMeta ??= new Map();
747
+ const prev = this.sessionMeta.get(sessionId) ?? {};
748
+ const meta = {
749
+ title: typeof title === "string" && title ? title : prev.title ?? null,
750
+ sessionUrl: typeof sessionUrl === "string" && sessionUrl ? sessionUrl : prev.sessionUrl ?? null,
751
+ repos: Array.isArray(repos) && repos.length ? repos.map((r) => (typeof r === "string" ? r : r?.key)).filter((k) => typeof k === "string") : prev.repos ?? [],
752
+ };
753
+ this.sessionMeta.set(sessionId, meta);
754
+ return meta;
619
755
  }
620
756
 
621
757
  /**
@@ -626,9 +762,12 @@ export class BridgeDaemon {
626
762
  */
627
763
  killOrphanedServices({ kill = process.kill, platform = process.platform } = {}) {
628
764
  const entries = this.readPreviewPids();
629
- 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)));
630
768
  let killed = 0;
631
769
  for (const e of entries) {
770
+ if (keep.has(e.pid)) continue;
632
771
  try {
633
772
  kill(e.pid, 0); // alive?
634
773
  } catch {
@@ -684,7 +823,7 @@ export class BridgeDaemon {
684
823
  /* already gone */
685
824
  }
686
825
  this.connectRealtime();
687
- }, REALTIME_RETRY_MS);
826
+ }, this.realtimeRetryMs ?? REALTIME_RETRY_MS);
688
827
  }
689
828
  }
690
829
 
@@ -700,6 +839,20 @@ export class BridgeDaemon {
700
839
  this.log("warn", "reconnect.hello.failed", { error: err.message });
701
840
  return;
702
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;
703
856
  try {
704
857
  const pending = await this.api.pendingTurns();
705
858
  // Cancels the server settled while we were away (reaper / watchdog):
@@ -709,24 +862,21 @@ export class BridgeDaemon {
709
862
  for (const turnId of cancelled) {
710
863
  const run = this.running.get(turnId);
711
864
  if (!run) continue;
712
- this.log("info", "turn.cancel.replayed", { turnId });
865
+ this.log("info", "turn.cancel.replayed", { turnId, via });
713
866
  run.ctrl.abort();
714
867
  }
715
868
  for (const turn of pending?.turns || []) {
716
869
  if (this.running.has(turn.turnId) || cancelled.has(String(turn.turnId))) continue;
717
- this.log("info", "turn.recovered", { turnId: turn.turnId });
870
+ this.log("info", "turn.recovered", { turnId: turn.turnId, via });
718
871
  void this.startTurn(turn).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
719
872
  }
720
- // Sign-in requests published while we were away: open their windows now.
721
- for (const req of pending?.loginRequests || []) {
722
- if (!req?.requestId || this.logins.has(req.requestId)) continue;
723
- this.log("info", "verify.login.recovered", { requestId: req.requestId });
724
- void this.loginStart(req).catch((err) => this.log("error", "verify.login.recover.failed", { error: err.message }));
725
- }
726
873
  } catch (err) {
727
- // Older server without the endpoint: the server-side sweep still
728
- // fails orphaned turns, so this is a nice-to-have, not a must.
729
- 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;
730
880
  }
731
881
  }
732
882
 
@@ -810,30 +960,33 @@ export class BridgeDaemon {
810
960
  entry.ctrl.abort();
811
961
  }
812
962
  }
813
- this.services.get(data.sessionId)?.stopAll();
814
- this.services.delete(data.sessionId);
815
- this.clearPreviewIdleTimer(data.sessionId);
816
- // A sign-in window opened for this session has nothing to sign in to any more.
817
- 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
+ }
818
977
  // Pre-0.4.0 daemons kept a browser profile per session; drop leftovers.
819
978
  rmSync(join(this.kaiHome, "browser", String(data.sessionId).replace(/[^\w.-]/g, "_")), { recursive: true, force: true });
820
979
  return;
821
- case "bridge.verify.login.start":
822
- return this.loginStart(data);
823
- case "bridge.verify.login.done":
824
- return this.loginDone(data);
825
- case "bridge.verify.login.cancel":
826
- return this.loginCancel(data);
827
- case "bridge.verify.evidence.retry":
828
- return this.retryEvidence(data);
829
980
  case "bridge.preview.start":
830
- // A preview the USER started stays up until they stop it (or it
831
- // 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).
832
982
  (this.manualPreviews ??= new Set()).add(data.sessionId);
833
- this.verifyOwnedPreviews?.delete(data.sessionId);
834
983
  return this.previewStart(data);
835
984
  case "bridge.preview.stop":
836
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);
837
990
  case "bridge.preview.keepalive":
838
991
  // Dashboard heartbeat while someone is actually LOOKING at the
839
992
  // preview — makes the idle auto-stop mean real idleness instead
@@ -856,19 +1009,23 @@ export class BridgeDaemon {
856
1009
  * truncation. `companionRemotes` (Server-resolved `{ key: remote }`) is
857
1010
  * how non-github companions get cloned; `note` survives onto error writes.
858
1011
  */
859
- async previewStart({ sessionId, title, repos, companionRemotes = null }) {
1012
+ async previewStart({ sessionId, title, repos, sessionUrl = null, companionRemotes = null, publicMode = false }) {
1013
+ this.rememberSession(sessionId, { title, sessionUrl, repos });
860
1014
  let lastNote = null;
861
1015
  const skipped = [];
862
- // Every report is also RETURNED: the verify turn boots the preview
863
- // 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).
864
1017
  const report = async (payload) => {
865
1018
  if (payload.note) lastNote = payload.note;
866
- const body = payload.status === "error" ? { ...payload, urls: payload.urls ?? [], previews: payload.previews ?? [], ...(lastNote && !payload.note ? { note: lastNote } : {}), ...(skipped.length && !payload.skipped ? { skipped } : {}) } : payload;
867
- 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);
868
1025
  return body;
869
1026
  };
870
1027
  const fail = (err, ctx = {}) => report(toPreviewErrorPayload(err, { ...ctx, skipped }));
871
- await report({ status: "starting" });
1028
+ await report({ status: "starting", ...(publicMode ? { note: "Restarting with the public address…" } : {}) });
872
1029
  let runner = null;
873
1030
  try {
874
1031
  // Pass 1 — resolve every session repo (checkout, env, config).
@@ -921,6 +1078,7 @@ export class BridgeDaemon {
921
1078
  const remotes = companionRemotes && typeof companionRemotes === "object" ? companionRemotes : {};
922
1079
  const { companions } = await collectCompanions({
923
1080
  roots: resolved.map((r) => ({ key: r.key, config: r.config })),
1081
+ extra: this.reverseCompanions(resolved),
924
1082
  maxDepth: COMPANION_MAX_DEPTH,
925
1083
  loadConfig: async (key) => {
926
1084
  try {
@@ -949,11 +1107,38 @@ export class BridgeDaemon {
949
1107
  }
950
1108
  const bootable = companions.filter((c) => c.config);
951
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
+
952
1128
  runner = this.runnerFor(sessionId);
953
1129
  // Pass 3 — ports for EVERY config (companions + session repos) before
954
1130
  // any boot, so `${port:x}` cross-references resolve whatever the order.
955
- for (const c of bootable) await runner.assignPorts(c.group.primary.path, c.config, { mode: "local", repoKey: c.key });
956
- 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
+ }
957
1142
 
958
1143
  // Pass 4 — boot companions (deepest first, local mode: an already
959
1144
  // running dev server of that repo is adopted), then the session repos
@@ -984,19 +1169,189 @@ export class BridgeDaemon {
984
1169
  }
985
1170
  }
986
1171
  const urls = [...previews, ...companionPreviews];
987
- // The landing page is a session repo's preview (never a companion's).
988
- const landing = previews[0] ?? companionPreviews[0] ?? null;
989
- this.armPreviewIdleTimer(sessionId);
990
- 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
+ });
991
1202
  } catch (err) {
992
1203
  this.log("error", "preview.start.failed", { sessionId, error: err.message, code: err?.code });
993
1204
  runner?.stopAll();
994
1205
  this.services.delete(sessionId);
995
1206
  this.clearPreviewIdleTimer(sessionId);
1207
+ if (publicMode) await this.tunnel.removeSession(sessionId);
996
1208
  return fail(err);
997
1209
  }
998
1210
  }
999
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
+
1000
1355
  /**
1001
1356
  * Config resolution order: this worktree's own config, then the PRIMARY
1002
1357
  * checkout's (a repo verified by the setup agent has its dev.yaml on an
@@ -1037,7 +1392,7 @@ export class BridgeDaemon {
1037
1392
 
1038
1393
  /**
1039
1394
  * The session's worktree for a repo: the exact slug when the title is
1040
- * 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
1041
1396
  * directory under `~/.kai/worktrees/<repo>/` that ends in the session's
1042
1397
  * id suffix — slugs are `<title>-<last 8 of the id>`, unique per session.
1043
1398
  */
@@ -1060,8 +1415,8 @@ export class BridgeDaemon {
1060
1415
  * `PreviewError` carrying the classified log line + code when a service
1061
1416
  * never becomes ready — a "running" preview must not 404.
1062
1417
  */
1063
- async bootRepoWithConfig(runner, cwd, config, mode, repoKey = null, opts = {}) {
1064
- 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 });
1065
1420
  const dead = started.services.find((s) => !s.adopted && !s.ready);
1066
1421
  if (dead) {
1067
1422
  throw new PreviewError(`${dead.name} did not start${dead.error ? ` — ${dead.error}` : ""}`, {
@@ -1139,15 +1494,100 @@ export class BridgeDaemon {
1139
1494
  }
1140
1495
  runner.stopAll();
1141
1496
  this.services.delete(sessionId);
1497
+ this.writePreviewSnapshot(sessionId, null);
1498
+ void this.tunnelManager?.removeSession(sessionId);
1142
1499
  this.log("info", "preview.idle.stopped", { sessionId });
1143
- this.api
1144
- .sessionPreview(sessionId, { status: "stopped", urls: [], previews: [], errorCode: "idle_stopped", error: "Stopped automatically after 30 minutes." })
1145
- .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." });
1146
1501
  }, this.previewIdleMs ?? 30 * 60 * 1000);
1147
1502
  t.unref?.();
1148
1503
  this.previewIdleTimers.set(sessionId, t);
1149
1504
  }
1150
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
+
1151
1591
  /** Any ESTABLISHED connection on one of the runner's (non-adopted) ports? Injectable for tests. */
1152
1592
  async previewHasConnections(runner) {
1153
1593
  const count = this.countConnections ?? ((port) => establishedConnections(port));
@@ -1166,13 +1606,12 @@ export class BridgeDaemon {
1166
1606
 
1167
1607
  async previewStop({ sessionId }) {
1168
1608
  this.manualPreviews?.delete(sessionId);
1169
- this.verifyOwnedPreviews?.delete(sessionId);
1170
- // A sign-in window for this session has nothing left to sign in to.
1171
- await this.teardownLogins((l) => l.sessionId === sessionId, "preview stopped");
1172
1609
  this.services.get(sessionId)?.stopAll();
1173
1610
  this.services.delete(sessionId);
1174
1611
  this.clearPreviewIdleTimer(sessionId);
1175
- 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: [] });
1176
1615
  }
1177
1616
 
1178
1617
  /**
@@ -1187,6 +1626,7 @@ export class BridgeDaemon {
1187
1626
  this.log("warn", "preview.service.died", { sessionId, name: info.name, code: info.code, errorCode: info.errorCode });
1188
1627
  runner.stopAll();
1189
1628
  this.services.delete(sessionId);
1629
+ this.writePreviewSnapshot(sessionId, null);
1190
1630
  this.clearPreviewIdleTimer(sessionId);
1191
1631
  return this.api
1192
1632
  .sessionPreview(
@@ -1207,20 +1647,17 @@ export class BridgeDaemon {
1207
1647
  let runner = this.services.get(sessionId);
1208
1648
  if (!runner) {
1209
1649
  // Stable ports (hash of repo + service in 43000-43999, when free) keep
1210
- // 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.
1211
1651
  runner = new ServiceRunner({
1212
1652
  kaiHome: this.kaiHome,
1213
1653
  sessionId,
1214
1654
  log: this.log,
1215
- onStatus: (message) => {
1216
- this.log("info", "preview.status", { sessionId, message });
1217
- this.previewStatusListeners?.get(sessionId)?.(message);
1218
- },
1655
+ onStatus: (message) => this.log("info", "preview.status", { sessionId, message }),
1219
1656
  preferredPort: ({ repoKey, service }) => preferredStablePort(repoKey, service),
1220
1657
  ...(this.describeListener ? { describeListener: this.describeListener } : {}),
1221
1658
  ...(this.previewSettleMs != null ? { settleMs: this.previewSettleMs } : {}),
1222
1659
  onServiceExit: (info) => this.onServiceDied(sessionId, info),
1223
- onProcess: (name, pid, op) => this.trackServicePid(sessionId, name, pid, op),
1660
+ onProcess: (name, pid, op, info) => this.trackServicePid(sessionId, name, pid, op, info),
1224
1661
  });
1225
1662
  this.services.set(sessionId, runner);
1226
1663
  }
@@ -1228,21 +1665,23 @@ export class BridgeDaemon {
1228
1665
  }
1229
1666
 
1230
1667
  /** Map the Server's repo bindings onto local checkouts; throw a readable error when one is missing. */
1231
- bindRepos(turn) {
1668
+ async bindRepos(turn) {
1232
1669
  const bound = [];
1233
1670
  for (const r of turn.repos || []) {
1234
1671
  const group = this.repoGroups.find((g) => g.key === r.key);
1235
1672
  if (!group) throw new Error(`Repository ${r.key} is not checked out on this device.`);
1236
1673
  const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
1237
- const ws = materializeBinding({
1674
+ const ws = await this.withGitAuth(r.key, (gitEnv) => materializeBinding({
1238
1675
  kaiHome: this.kaiHome,
1239
1676
  repo: { name: group.name, primaryPath: group.primary.path, defaultBranch: group.primary.defaultBranch },
1240
1677
  binding: { mode, base: r.base, carryUncommitted: r.carryUncommitted },
1241
1678
  sessionId: turn.sessionId,
1242
1679
  title: turn.title,
1243
- });
1680
+ gitEnv,
1681
+ }));
1244
1682
  bound.push({ key: r.key, ...ws });
1245
1683
  if (ws.deps) this.log("info", "deps.seed", { repo: r.key, ...ws.deps });
1684
+ if (ws.fetch && (ws.fetch.stale || ws.fetch.attempts > 1)) this.log("warn", "workspace.fetch.contended", { repo: r.key, ...ws.fetch });
1246
1685
  // Remember the choice per repo (the UI asks once, then sticks).
1247
1686
  this.config.repoModes = { ...(this.config.repoModes || {}), [r.key]: mode };
1248
1687
  }
@@ -1305,117 +1744,58 @@ export class BridgeDaemon {
1305
1744
  this.running.set(turnId, entry);
1306
1745
  const releaseAwake = keepAwake();
1307
1746
  let outcome = null;
1308
- // Verify turns (`agent: kai-verifier`): preview booted up front, browser
1309
- // evidence collected into an artifacts dir, report uploaded after the
1310
- // turn — see prepareVerifyTurn / finishVerification.
1311
- const isVerify = turn.agent === "kai-verifier";
1312
- let verify = null;
1313
- this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null });
1747
+ // Everything `kai-bridge ps` shows for this turn; the runner pid follows at spawn.
1748
+ const session = this.rememberSession(turn.sessionId, { title: turn.title, sessionUrl: turn.sessionUrl, repos: turn.repos });
1749
+ this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null, harness: turn.harness ?? null, profileId: turn.profileId ?? null, startedAt: new Date().toISOString(), ...session });
1314
1750
  const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
1315
1751
  try {
1316
1752
  const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === turn.profileId) ?? { id: "gleap-key", kind: "gleap-key", harness: turn.harness };
1317
- const bound = this.bindRepos(turn);
1753
+ const bound = await this.bindRepos(turn);
1318
1754
  // Multi-repo: the runner's cwd is the first repo; the others are
1319
1755
  // reachable as siblings under the same worktree root or by their
1320
1756
  // local paths — the prompt lists them.
1321
1757
  const workDir = bound[0]?.cwd;
1322
1758
  if (!workDir) throw new Error("Turn has no repositories.");
1323
- const repoNote = isVerify ? this.verifyRepoBrief(bound) : this.repoBrief(turn, bound);
1324
- let previewNote = "";
1325
- let mcpServers = turn.mcpServers;
1326
- if (isVerify) {
1327
- verify = await this.prepareVerifyTurn(turn, bound);
1328
- if (!verify.ok) {
1329
- // Nothing to test against (preview dead) or a sign-in wall the
1330
- // user must clear first: file a blocked report and close the turn
1331
- // cleanly without spending an agent run.
1332
- await this.api
1333
- .turnVerification(turnId, { ...buildVerificationPayload(null, [], { fallbackReason: verify.error }), ...(verify.blocked || {}) })
1334
- .catch((err) => this.log("warn", "verify.report.failed", { turnId, error: err.message }));
1335
- outcome = {
1336
- status: "completed",
1337
- result: null,
1338
- changes: bound.map((b) => ({ key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, files: [], push: false })),
1339
- profileId: profile.id,
1340
- };
1341
- return;
1342
- }
1343
- previewNote = verify.note;
1344
- // The evidence dir is where a restart mid-run finds partial footage.
1345
- this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent, artifactsDir: verify.artifactsDir });
1346
- mcpServers = [
1347
- ...(turn.mcpServers || []),
1348
- previewMcpServer(RUNNER_DIR, {
1349
- outputDir: verify.artifactsDir,
1350
- // `storage` = browser_storage_state for the final sign-in refresh;
1351
- // restoring an arbitrary state file is never the agent's call.
1352
- caps: ["devtools", "testing", "storage"],
1353
- secretsFile: verify.secretsFile,
1354
- storageStateFile: verify.storageStateFile,
1355
- allowedOrigins: verify.storageStateFile ? verify.allowedOrigins : null,
1356
- disabledTools: ["browser_set_storage_state"],
1357
- ignoreHttpsErrors: !!verify.ignoreHttpsErrors,
1358
- }),
1359
- ];
1360
- await this.postVerifyStage(turnId, "verifying");
1361
- } else {
1362
- // Previews are manual-only (dashboard "Start preview") — a turn
1363
- // never boots dev servers on its own. When the user already has a
1364
- // preview running for this session, describe it to the agent and
1365
- // hand it the Playwright MCP so it can verify in a real browser.
1366
- const live = await this.describeLivePreview(turn, bound, batcher);
1367
- previewNote = live.note;
1368
- if (live.hasLivePreview) mcpServers = [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)];
1369
- }
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;
1370
1767
  const res = await runTurn({
1371
1768
  turn: { ...turn, task: `${turn.task}${repoNote}${previewNote}`, mcpServers },
1372
1769
  profile,
1373
1770
  workDir,
1374
1771
  kaiHome: this.kaiHome,
1375
- // The verify MCP's request policy (KAI_VERIFY_*) — host-only, never in the prompt.
1376
- extraEnv: verify?.env ?? null,
1377
1772
  signal: ctrl.signal,
1378
1773
  onSpawn: (handle) => {
1379
1774
  entry.control = handle.control;
1775
+ if (Number.isInteger(handle.pid)) this.rememberInflight(turnId, { pid: handle.pid });
1380
1776
  },
1381
- onEvent: (ev) => {
1382
- if (verify) {
1383
- // The tester is using the preview — it must not idle-stop
1384
- // under it. And the report carries LOCAL paths: keep it here,
1385
- // the uploaded version goes out after the turn.
1386
- this.armPreviewIdleTimer(turn.sessionId);
1387
- if (ev?.type === "verify_report") {
1388
- verify.report = ev.report && typeof ev.report === "object" ? ev.report : null;
1389
- return;
1390
- }
1391
- // Injected sign-in values (cookies, tokens) must never reach
1392
- // the Server in a tool row or snapshot.
1393
- if (verify.redact?.size) ev = redactDeep(ev, verify.redact);
1394
- }
1395
- batcher.push(ev);
1396
- },
1777
+ onEvent: (ev) => batcher.push(ev),
1397
1778
  onLog: (l) => this.log("debug", "runner", { line: l.slice(0, 500) }),
1398
1779
  });
1399
1780
  await batcher.flush();
1400
1781
  const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
1401
- // Plan and verify turns are read-only: worktrees are restored, local
1402
- // checkouts only reported, nothing is ever committed or pushed.
1403
- const readOnlyTurn = !!turn.planMode || isVerify;
1404
- const changes = [...bound, ...this.adoptSessionWorktrees(turn, bound)].map((b) => {
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;
1785
+ const changes = await Promise.all([...bound, ...this.adoptSessionWorktrees(turn, bound)].map(async (b) => {
1405
1786
  ensureCommitExcludes(b.cwd, { allowDevConfig: !!turn.allowDevConfig });
1406
1787
  // A read-only turn must leave the worktree as it found it — see
1407
1788
  // discardChanges. Local checkouts are the user's; only report.
1408
1789
  if (readOnlyTurn) {
1409
1790
  const leaked = b.mode === "worktree" ? discardChanges(b.cwd).discarded : collectChanges(b.cwd).files;
1410
1791
  if (leaked.length > 0) {
1411
- const mode = turn.planMode ? "Plan mode" : "Verification";
1412
- 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" });
1413
1793
  batcher.push({
1414
1794
  type: "text",
1415
1795
  message:
1416
1796
  b.mode === "worktree"
1417
- ? `${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" : ""}.`
1418
- : `${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.`,
1419
1799
  });
1420
1800
  }
1421
1801
  }
@@ -1423,30 +1803,32 @@ export class BridgeDaemon {
1423
1803
  // Build turns in worktree mode publish the session branch so the
1424
1804
  // Server can open the PR; read-only turns and local mode never push.
1425
1805
  const shouldPush = completed && b.mode === "worktree" && !readOnlyTurn && diff.files.length > 0;
1806
+ const pushOnce = (gitEnv) => {
1807
+ const out = commitAndPush(b.cwd, {
1808
+ branch: b.branch,
1809
+ allowDevConfig: !!turn.allowDevConfig,
1810
+ message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}`,
1811
+ gitEnv,
1812
+ });
1813
+ // commitAndPush reports instead of throwing; surface an auth
1814
+ // rejection so withGitAuth can retry the push (the commit exists).
1815
+ if (!out.pushed && !gitEnv && isGitAuthError(out.error)) throw Object.assign(new Error(out.error), { push: out });
1816
+ return out;
1817
+ };
1426
1818
  const push = shouldPush
1427
- ? commitAndPush(b.cwd, {
1428
- branch: b.branch,
1429
- allowDevConfig: !!turn.allowDevConfig,
1430
- message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}`,
1431
- })
1432
- : isVerify
1433
- ? false
1434
- : null;
1819
+ ? await this.withGitAuth(b.key, pushOnce).catch((err) => err.push ?? { committed: false, pushed: false, branch: b.branch, error: err.message })
1820
+ : null;
1435
1821
  // `cwd` travels with the change so the dashboard can point at
1436
1822
  // work that stayed on this machine (files but no push).
1437
1823
  // Pushed → the commit list + diff stat travel with the change so the
1438
1824
  // Server can write a real pull request description.
1439
1825
  const described = push?.pushed ? describeBranchChanges(b.cwd, b.base) : null;
1440
1826
  return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, adopted: b.adopted || undefined, ...diff, push, ...(described ? described : {}) };
1441
- });
1827
+ }));
1442
1828
  // The read-only notices above were queued AFTER the post-turn
1443
1829
  // flush; land them before the result closes the turn (the Server
1444
1830
  // answers 410 for events on an ended turn).
1445
1831
  await batcher.flush();
1446
- if (verify) {
1447
- const finished = await this.finishVerification(turn, { ...verify, workDir, bound }, res, ctrl.signal.aborted);
1448
- await this.settleVerifyPreview(turn, finished?.status).catch((err) => this.log("warn", "verify.preview.release.failed", { turnId, error: err.message }));
1449
- }
1450
1832
  // Built OUTSIDE the report call: if posting the result throws, the
1451
1833
  // catch below must not turn a finished turn into a failed one. The
1452
1834
  // work is already committed and pushed at this point.
@@ -1454,17 +1836,19 @@ export class BridgeDaemon {
1454
1836
  status: ctrl.signal.aborted ? "cancelled" : res.rateLimited ? "rate_limited" : res.code === 0 ? "completed" : "failed",
1455
1837
  exitCode: res.code,
1456
1838
  ...(res.errorCode ? { errorCode: res.errorCode } : {}),
1457
- result: verify?.redact?.size ? redactDeep(res.result, verify.redact) : res.result,
1839
+ result: res.result,
1458
1840
  // The runner's own failure text (e.g. the engine's usage-limit
1459
1841
  // message) — the Server prefers this over its generic fallback.
1460
- ...(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) } : {}),
1461
1843
  changes,
1462
1844
  profileId: profile.id,
1463
1845
  };
1464
1846
  } catch (err) {
1465
- this.log("error", "turn.failed", { turnId, error: err.message });
1847
+ this.log("error", "turn.failed", { turnId, error: err.message, ...(err.code ? { code: err.code, repo: err.repo } : {}) });
1466
1848
  await batcher.flush().catch(() => {});
1467
- outcome = outcome ?? { status: "failed", error: err.message };
1849
+ // A WorkspaceError names why the checkout could not be prepared;
1850
+ // the Server turns `failure.code` into a one-click retry.
1851
+ outcome = outcome ?? { status: "failed", error: err.message, ...(err.code ? { failure: { code: err.code, repo: err.repo ?? null } } : {}) };
1468
1852
  } finally {
1469
1853
  // One report, retried until it lands — a dropped result is what
1470
1854
  // leaves a session spinning forever in the dashboard.
@@ -1476,845 +1860,23 @@ export class BridgeDaemon {
1476
1860
  })
1477
1861
  .catch((err) => this.log("error", "result.lost", { turnId, error: err.message }));
1478
1862
  }
1479
- // Test credentials and the injected / final sign-in state live on
1480
- // disk only for the duration of the turn.
1481
- for (const f of [verify?.secretsFile, verify?.storageStateFile, verify?.finalStateFile]) if (f) rmSync(f, { force: true });
1482
- verify?.release?.();
1483
1863
  this.forgetInflight(turnId);
1484
1864
  releaseAwake();
1485
1865
  this.running.delete(turnId);
1486
- 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();
1487
1867
  }
1488
1868
  }
1489
1869
 
1490
- // ── verify turns ───────────────────────────────────────────────────
1491
- /** 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. */
1492
1872
  ensurePreviewBrowser() {
1493
1873
  return ensurePreviewBrowser({ runnerDir: RUNNER_DIR, log: this.log });
1494
1874
  }
1495
1875
 
1496
- /** Seams for the sign-in flows (tests inject a fake Playwright / probe / capture). */
1876
+ /** Seam for the warm-up (tests inject a fake Playwright). */
1497
1877
  loadPlaywright() {
1498
1878
  return loadPlaywright(RUNNER_DIR);
1499
1879
  }
1500
- displayAvailable() {
1501
- return displayAvailable();
1502
- }
1503
- probeLogin(opts) {
1504
- return probeLogin(opts);
1505
- }
1506
- captureLogin(opts) {
1507
- return captureLogin(opts);
1508
- }
1509
-
1510
- /** Where a verify turn's injected sign-in state lives (0600, deleted with the turn). */
1511
- loginStateDir() {
1512
- return join(this.kaiHome, "state", "preview-login");
1513
- }
1514
-
1515
- /** The agent's final browser-state export — inside the turn's evidence dir (the MCP's only writable root besides the repo). */
1516
- finalStateFileFor(artifactsDir) {
1517
- return join(artifactsDir, "state", "final-storage-state.json");
1518
- }
1519
-
1520
- /** Best-effort stage transition for the dashboard's Verify card (booting → verifying → saving). */
1521
- async postVerifyStage(turnId, stage, note) {
1522
- // The note often relays the agent's last line — markdown emphasis
1523
- // ("**Confirming…**") must not reach the card as literal asterisks.
1524
- const plain = typeof note === "string" ? note.replace(/[#*`>_]/g, "").replace(/\s+/g, " ").trim().slice(0, 200) : "";
1525
- try {
1526
- await this.api.turnVerificationStage(turnId, { stage, ...(plain ? { note: plain } : {}) });
1527
- } catch (err) {
1528
- this.log("debug", "verify.stage.failed", { turnId, stage, error: err?.message });
1529
- }
1530
- }
1531
-
1532
- /**
1533
- * One probe / refresh of a sign-in at a time per user + repo: two verify
1534
- * turns racing the same record would both refresh it (one 409s) and
1535
- * could probe a preview the other is still booting.
1536
- */
1537
- withVerifyLock(key, fn) {
1538
- return this.acquireVerifyLock(key).then(async (release) => {
1539
- try {
1540
- return await fn();
1541
- } finally {
1542
- release();
1543
- }
1544
- });
1545
- }
1546
-
1547
- /**
1548
- * Acquire the per user+repo verify lock; resolves with `release()`. A
1549
- * turn that injected a saved sign-in keeps it until its final refresh
1550
- * has landed: two agent runs presenting the same refresh token in
1551
- * parallel is exactly what rotation-based IdPs revoke on.
1552
- */
1553
- acquireVerifyLock(key) {
1554
- this.verifyLockHolders ??= new Set(); // keys currently HELD (the map also lists waiters)
1555
- this.verifyLocks ??= new Map();
1556
- const prev = this.verifyLocks.get(key) ?? Promise.resolve();
1557
- let release;
1558
- const held = new Promise((resolve) => {
1559
- let done = false;
1560
- release = () => {
1561
- if (done) return;
1562
- done = true;
1563
- this.verifyLockHolders.delete(key);
1564
- resolve();
1565
- };
1566
- });
1567
- const next = prev.catch(() => {}).then(() => held);
1568
- this.verifyLocks.set(key, next);
1569
- next.finally(() => {
1570
- if (this.verifyLocks.get(key) === next) this.verifyLocks.delete(key);
1571
- });
1572
- return prev
1573
- .catch(() => {})
1574
- .then(() => {
1575
- this.verifyLockHolders.add(key);
1576
- return release;
1577
- });
1578
- }
1579
-
1580
- /** The verifier's repo brief: where the checkouts are, and that they are read-only. */
1581
- verifyRepoBrief(bound) {
1582
- 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):"];
1583
- for (const b of bound) lines.push(`- ${b.key}: ${b.cwd}${b.branch ? ` (branch ${b.branch}${b.base ? `, base ${b.base}` : ""})` : ""}`);
1584
- return lines.join("\n");
1585
- }
1586
-
1587
- /**
1588
- * dev.yaml `auth.storageState: <command>` — run in the repo checkout,
1589
- * stdout is Playwright storageState JSON (or the path of a JSON file).
1590
- * Replaces the user's saved sign-in for this repo. Null on any failure.
1591
- */
1592
- async storageStateFromCommand(command, cwd) {
1593
- return new Promise((resolveP) => {
1594
- const cb = (err, stdout) => {
1595
- if (err) {
1596
- this.log("warn", "verify.login.command.failed", { error: err.message });
1597
- return resolveP(null);
1598
- }
1599
- resolveP(parseStorageStateOutput(stdout, { read: (p) => readFileSync(p, "utf8") }));
1600
- };
1601
- const opts = { cwd, timeout: 60_000, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, BROWSER: "none" } };
1602
- if (process.platform === "darwin") execFile("/bin/zsh", ["-lc", command], opts, cb);
1603
- else execFile(command, [], { ...opts, shell: true }, cb);
1604
- });
1605
- }
1606
-
1607
- /**
1608
- * Boot (or re-describe) the session's preview, make sure a browser can
1609
- * launch, decide the sign-in (dev.yaml command → saved record → none),
1610
- * PROBE it headless, and lay out the turn's evidence dir + state files +
1611
- * optional secrets file. Resolves `{ ok: false, error, blocked? }`
1612
- * (`blocked` = `{ blockedCode, loginPath?, firstTime? }` for the report)
1613
- * or `{ ok: true, artifactsDir, secretsFile, storageStateFile,
1614
- * finalStateFile, allowedOrigins, injected, redact, note, report: null }`.
1615
- */
1616
- async prepareVerifyTurn(turn, bound = []) {
1617
- const repoKeys = (turn.repos || []).map((r) => r.key).filter(Boolean).sort();
1618
- const lockKey = `${turn.userId ?? turn.ownerUserId ?? "device"}:${repoKeys.join(",")}`;
1619
- if (this.verifyLockHolders?.has(lockKey)) {
1620
- // Another verify run of this repo holds its sign-in — say so instead
1621
- // of sitting on "Starting the app…" until it finishes.
1622
- this.log("info", "verify.lock.wait", { turnId: turn.turnId, lockKey });
1623
- await this.postVerifyStage(turn.turnId, "booting", "Waiting for another verification of this repo on this machine to finish…");
1624
- }
1625
- const release = await this.acquireVerifyLock(lockKey);
1626
- let result;
1627
- try {
1628
- result = await this.prepareVerifyTurnLocked(turn, bound);
1629
- } catch (err) {
1630
- release();
1631
- throw err;
1632
- }
1633
- if (result?.ok && result.injected) {
1634
- // Hold the repo's sign-in for the whole run — released by startTurn's
1635
- // finally (after finishVerification refreshed the record).
1636
- return { ...result, release };
1637
- }
1638
- release();
1639
- return result;
1640
- }
1641
-
1642
- async prepareVerifyTurnLocked(turn, bound) {
1643
- const { turnId, sessionId } = turn;
1644
- // Lazy browser check — setup usually did this; a machine that skipped
1645
- // it downloads Chromium now (best-effort: without a browser the agent
1646
- // reports `blocked` itself).
1647
- const browser = await this.ensurePreviewBrowser().catch((err) => ({ ok: false, error: err?.message }));
1648
- if (!browser?.ok) {
1649
- // No browser, no test — say so with the exact command (Linux/Windows
1650
- // machines without Chrome and a failed Chromium download) instead of
1651
- // letting the agent discover it on its first navigate.
1652
- this.log("warn", "verify.browser.unavailable", { turnId, error: browser?.error });
1653
- return {
1654
- ok: false,
1655
- 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.` : ""}`,
1656
- blocked: { blockedCode: "no_browser" },
1657
- };
1658
- }
1659
- const channel = browser?.browser === "chrome" ? "chrome" : null;
1660
- // previewStart is idempotent on a live runner (booted services are
1661
- // only re-described) and reports starting/running to the dashboard
1662
- // exactly like the manual button. A cold boot is its own stage, and
1663
- // every runner status line during it (installing, still starting,
1664
- // ready) reaches the Verify card as a `booting` note — the heartbeat.
1665
- const coldBoot = !this.services.has(sessionId);
1666
- if (coldBoot && !this.manualPreviews?.has(sessionId)) (this.verifyOwnedPreviews ??= new Set()).add(sessionId);
1667
- if (coldBoot) await this.postVerifyStage(turnId, "booting");
1668
- const heartbeat = createStageHeartbeat((note) => this.postVerifyStage(turnId, "booting", note), { minIntervalMs: this.stageHeartbeatMs ?? 5_000 });
1669
- this.previewStatusListeners ??= new Map();
1670
- this.previewStatusListeners.set(sessionId, heartbeat.note);
1671
- let preview;
1672
- try {
1673
- preview = await this.previewStart({ sessionId, title: turn.title, repos: turn.repos, companionRemotes: turn.companionRemotes ?? null });
1674
- } finally {
1675
- this.previewStatusListeners?.delete(sessionId);
1676
- heartbeat.stop();
1677
- }
1678
- if (preview?.status !== "running") {
1679
- // The preview's structured error IS the diagnosis: its code becomes the
1680
- // verification's blockedCode (companion_missing, port_busy, deps_failed,
1681
- // …) so the card offers the right verb; `preview_unreachable` only
1682
- // when nothing more specific is known.
1683
- const code = preview?.errorCode && preview.errorCode !== "other" ? preview.errorCode : "preview_unreachable";
1684
- return {
1685
- ok: false,
1686
- error: preview?.error ? `The preview could not be started: ${preview.error}` : "The preview could not be started.",
1687
- blocked: { blockedCode: code },
1688
- };
1689
- }
1690
- // After a fix turn, services that do not hot-reload (`reload: restart`,
1691
- // the default for APIs) must run the fixed code before the probe.
1692
- const restartNotes = await this.restartChangedServices(turn, sessionId);
1693
- const artifactsDir = join(this.kaiHome, "artifacts", String(turnId).replace(/[^\w.-]/g, "_"));
1694
- mkdirSync(artifactsDir, { recursive: true });
1695
- let secretsFile = null;
1696
- let secretNames = [];
1697
- if (turn.verifySecrets && typeof turn.verifySecrets === "object") {
1698
- const dir = join(this.kaiHome, "state", "verify-secrets");
1699
- mkdirSync(dir, { recursive: true });
1700
- const path = join(dir, `${String(turnId).replace(/[^\w.-]/g, "_")}.env`);
1701
- secretNames = writeSecretsFile(path, turn.verifySecrets);
1702
- if (secretNames.length > 0) secretsFile = path;
1703
- }
1704
- const previews = preview.previews || [];
1705
- const runner = this.services.get(sessionId);
1706
- const services = runner?.describeServices?.() ?? previews.map((p) => ({ name: p.name, url: p.url }));
1707
- const previewOrigins = [...new Set([...services.map((s) => normalizeOrigin(s.url)), ...previews.map((p) => normalizeOrigin(p.url))].filter(Boolean))];
1708
- const primaryKey = (turn.repos || [])[0]?.key;
1709
- // The landing page is a session repo's web preview (an API has no UI to land on).
1710
- const webPreviews = previews.filter((p) => p.kind !== "api");
1711
- 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;
1712
-
1713
- // ── sign-in: dev.yaml command → saved record → none ──────────────
1714
- const primary = bound?.[0] ?? null;
1715
- const devConfig = primary?.cwd ? readDevConfig(primary.cwd) : null;
1716
- const auth = devConfig?.auth ?? null;
1717
- // Every dev.yaml in the session (external origins, verify policy) — the
1718
- // primary's wins on conflicts.
1719
- const configs = (bound || []).map((b) => (b?.cwd ? readDevConfig(b.cwd) : null)).filter(Boolean);
1720
- const external = [...new Set(configs.flatMap((c) => c.external || []))];
1721
- // The state filter accepts the declared `external` origins too: the
1722
- // browser is allowed to talk to them, so a token the app keeps there is
1723
- // part of "signed in". Cookies stay localhost-only (filterStorageState).
1724
- const stateOrigins = [...new Set([...previewOrigins, ...external])];
1725
- let injected = null; // { source: "command" | "record", state, record?, originMap? }
1726
- if (auth?.storageState && primary?.cwd) {
1727
- const state = await this.storageStateFromCommand(auth.storageState, primary.cwd);
1728
- if (state) injected = { source: "command", state: filterStorageState(state, stateOrigins).state, record: null, originMap: null };
1729
- else this.log("warn", "verify.login.command.empty", { turnId });
1730
- } else if (turn.loginRecordAvailable) {
1731
- const record = await this.api.turnPreviewLogin(turnId).catch((err) => {
1732
- this.log("warn", "verify.login.record.failed", { turnId, error: err?.message });
1733
- return null;
1734
- });
1735
- if (record?.storageState) {
1736
- const originMap = buildOriginMap(record.services, services);
1737
- const state = rewriteStorageState(record.storageState, originMap);
1738
- this.log("info", "verify.login.record", { turnId, version: record.version, mapped: originMap.size, cookies: state.cookies.length, origins: state.origins.length });
1739
- injected = { source: "record", state, record, originMap };
1740
- }
1741
- }
1742
-
1743
- // ── probe: does the preview consider us signed in? ───────────────
1744
- // An API-only preview (no web service) has nothing to render a login
1745
- // wall on — the agent's http_request answers 401/403 → needs_login.
1746
- let probe = { result: "unknown", storageState: null, loginPath: null };
1747
- const hasWebService = services.some((svc) => svc.kind !== "api") || webPreviews.length > 0;
1748
- if (landingUrl && hasWebService) {
1749
- const pw = this.loadPlaywright();
1750
- probe = await this.probeLogin({
1751
- pw,
1752
- launchOptions: launchOptionsFor({ headless: true, browser: channel }),
1753
- storageState: injected?.state ?? null,
1754
- url: injected?.record?.landingUrl ? rewriteUrl(injected.record.landingUrl, injected.originMap) ?? landingUrl : landingUrl,
1755
- previewOrigins,
1756
- loginPaths: injected?.record?.loginPaths ?? [],
1757
- loginCheck: auth?.loginCheck ?? null,
1758
- // A dev server that just booted still compiles its first page (vite's
1759
- // cold transform of a large app takes 30-60 s) — give it room, or the
1760
- // probe answers `unknown` and a login wall goes unnoticed.
1761
- timeoutMs: coldBoot ? PROBE_TIMEOUT_COLD_BOOT_MS : undefined,
1762
- log: this.log,
1763
- });
1764
- }
1765
- // "Continue without signing in" (`previewLoginOptional`) means: run
1766
- // unauthenticated rather than block — whether there is no record at all
1767
- // or the saved one no longer works.
1768
- if (probe.result === "needs_login" && turn.previewLoginOptional === true) {
1769
- this.log("info", "verify.login.optional", { turnId, hadRecord: injected !== null });
1770
- injected = null;
1771
- probe = { result: "unknown", storageState: null, loginPath: probe.loginPath };
1772
- }
1773
- if (probe.result === "needs_login") {
1774
- rmSync(artifactsDir, { recursive: true, force: true });
1775
- if (secretsFile) rmSync(secretsFile, { force: true });
1776
- const firstTime = injected === null;
1777
- return {
1778
- ok: false,
1779
- error: firstTime
1780
- ? "The preview asks for a sign-in. Sign in once on this device so Kai can test the app."
1781
- : "The saved sign-in for this preview no longer works — sign in again on this device.",
1782
- blocked: { blockedCode: "needs_login", ...(probe.loginPath ? { loginPath: probe.loginPath } : {}), firstTime },
1783
- };
1784
- }
1785
-
1786
- // ── state files for the MCP ──────────────────────────────────────
1787
- let storageStateFile = null;
1788
- let finalStateFile = null;
1789
- let redact = new Set();
1790
- let probeExport = null;
1791
- if (injected && !storageStateIsEmpty(injected.state)) {
1792
- const dir = this.loginStateDir();
1793
- mkdirSync(dir, { recursive: true, mode: 0o700 });
1794
- const safe = String(turnId).replace(/[^\w.-]/g, "_");
1795
- storageStateFile = join(dir, `${safe}.json`);
1796
- // The persona's final `browser_storage_state` export must land INSIDE
1797
- // the MCP's `--output-dir`: the Playwright MCP refuses any `filename`
1798
- // outside its output dir / the client cwd (`File access denied …
1799
- // outside allowed roots`), so a path under ~/.kai/state would make the
1800
- // agent's last action fail on every signed-in run and the record would
1801
- // never refresh. A `.json` is not an artifact extension — the sweep
1802
- // never uploads it — and it is read before the evidence dir is deleted.
1803
- finalStateFile = this.finalStateFileFor(artifactsDir);
1804
- mkdirSync(dirname(finalStateFile), { recursive: true, mode: 0o700 });
1805
- // The probe context's own export (with IndexedDB) when the probe ran;
1806
- // the rewritten record otherwise (probe unknown → proceed anyway).
1807
- probeExport = probe.result === "ok" && probe.storageState ? filterStorageState(probe.storageState, stateOrigins).state : injected.state;
1808
- writeFileSync(storageStateFile, JSON.stringify(probeExport), { mode: 0o600 });
1809
- redact = new Set([...redactionSet(injected.state), ...redactionSet(probeExport)]);
1810
- }
1811
- // `.env*` values of the repos + service dirs are secrets too: a tool row
1812
- // that echoes one (a config dump, an error message) must not carry it
1813
- // to the Server.
1814
- for (const value of this.envRedactionValues(bound, services)) redact.add(value);
1815
- const allowedOrigins = [...previewOrigins, ...external, "http://localhost:*", "http://127.0.0.1:*", "https://localhost:*", "https://127.0.0.1:*"];
1816
- // ── API evidence: the verify MCP's request policy ───────────────
1817
- const apiServices = services.filter((svc) => svc.kind === "api");
1818
- const verifyPolicy = configs.find((c) => c.verify)?.verify ?? devConfig?.verify ?? { readOnly: null, endpoints: [], loginVia: null };
1819
- const readOnly = verifyPolicy.readOnly ?? apiServices.length > 0;
1820
- const env = {
1821
- KAI_VERIFY_READ_ONLY: readOnly ? "1" : "0",
1822
- KAI_VERIFY_ORIGINS: [...new Set([...previewOrigins, ...external])].join(";"),
1823
- KAI_VERIFY_EVIDENCE_DIR: artifactsDir,
1824
- };
1825
- // The app's own API credential (dev.yaml `auth.apiToken`) read from the
1826
- // injected sign-in → `Authorization: Bearer …` for http_request. The
1827
- // value goes to the MCP by env only and joins the redaction set.
1828
- const apiAuth = deriveApiAuthHeader(auth?.apiToken, probeExport ?? injected?.state ?? null, services);
1829
- if (apiAuth) {
1830
- env.KAI_VERIFY_AUTH_HEADER = apiAuth.value;
1831
- if (apiAuth.token.length >= 8) redact.add(apiAuth.token);
1832
- }
1833
- return {
1834
- ok: true,
1835
- artifactsDir,
1836
- secretsFile,
1837
- storageStateFile,
1838
- finalStateFile,
1839
- allowedOrigins,
1840
- previewOrigins,
1841
- stateOrigins,
1842
- external,
1843
- // Companions run the code they have checked out — their HEADs travel
1844
- // with the report so "verified" can never be claimed for a stale API.
1845
- companionRevisions: previews
1846
- .filter((p) => p.role === "companion" && p.repo && p.commit)
1847
- .map((p) => ({ repo: p.repo, commit: p.commit, role: "companion", ...(p.branch ? { branch: p.branch } : {}), ...(p.dirty > 0 ? { dirty: p.dirty } : {}) })),
1848
- ignoreHttpsErrors: !!runner?.usesHttps?.(),
1849
- env,
1850
- readOnly,
1851
- injected: injected ? { ...injected, probeExport } : null,
1852
- redact,
1853
- note: this.verifyPreviewNote(sessionId, previews, secretNames, artifactsDir, {
1854
- signedIn: !!storageStateFile,
1855
- finalStateFile,
1856
- services,
1857
- readOnly,
1858
- endpoints: verifyPolicy.endpoints || [],
1859
- external,
1860
- restartNotes,
1861
- apiAuth: !!apiAuth,
1862
- }),
1863
- report: null,
1864
- };
1865
- }
1866
-
1867
- /**
1868
- * After a fix turn (`turn.changedRepos` = repo keys the fix touched):
1869
- * restart the changed repos' services whose reload mode is `restart`
1870
- * (declared, or the default for `kind: api`) on their same ports. Adopted
1871
- * services are the user's own dev server — never restarted, the task
1872
- * note says so. Returns the note lines for the agent.
1873
- */
1874
- async restartChangedServices(turn, sessionId) {
1875
- const changed = Array.isArray(turn.changedRepos) ? turn.changedRepos.map((k) => String(k).toLowerCase()) : [];
1876
- const runner = this.services.get(sessionId);
1877
- if (!changed.length || !runner) return [];
1878
- const notes = [];
1879
- const byRoot = new Map(); // repoRoot → { key, names }
1880
- for (const svc of runner.describeServices()) {
1881
- if (!svc.repoKey || !changed.includes(String(svc.repoKey).toLowerCase()) || !svc.repoRoot) continue;
1882
- const meta = runner.meta?.get(svc.name);
1883
- if (effectiveReload(meta?.svc, svc.kind) !== "restart") continue;
1884
- if (svc.adopted) {
1885
- notes.push(`${svc.name} is your already-running dev server and was not restarted — make sure it serves the fixed code.`);
1886
- continue;
1887
- }
1888
- const entry = byRoot.get(svc.repoRoot) ?? { key: svc.repoKey, names: [] };
1889
- entry.names.push(svc.name);
1890
- byRoot.set(svc.repoRoot, entry);
1891
- }
1892
- for (const [repoRoot, { key, names }] of byRoot) {
1893
- await this.postVerifyStage(turn.turnId, "booting", `Restarting ${names.join(", ")} with the fix…`);
1894
- this.log("info", "verify.restart", { turnId: turn.turnId, repo: key, services: names });
1895
- try {
1896
- const restarted = await runner.restart(repoRoot, { only: names });
1897
- const dead = restarted.find((r) => r.restarted && !r.ready);
1898
- if (dead) notes.push(`${dead.name} did not come back after the restart${dead.error ? ` — ${dead.error}` : ""} (see ${dead.logPath}).`);
1899
- else notes.push(`${names.join(", ")} ${names.length === 1 ? "was" : "were"} restarted with the fix.`);
1900
- } catch (err) {
1901
- this.log("warn", "verify.restart.failed", { turnId: turn.turnId, repo: key, error: err?.message });
1902
- notes.push(`${names.join(", ")} could not be restarted with the fix: ${err?.message}`);
1903
- }
1904
- }
1905
- return notes;
1906
- }
1907
-
1908
- /** Secrets from `.env*` files at the repo roots + service cwds (values ≥ 12 chars, placeholders excluded). */
1909
- envRedactionValues(bound, services) {
1910
- const dirs = new Set([...(bound || []).map((b) => b?.cwd).filter(Boolean), ...(services || []).map((svc) => svc?.cwd).filter(Boolean)]);
1911
- const texts = [];
1912
- for (const dir of dirs) {
1913
- let names = [];
1914
- try {
1915
- names = readdirSync(dir).filter((n) => n === ".env" || (n.startsWith(".env.") && !/\.(example|sample|template)$/.test(n)));
1916
- } catch {
1917
- continue;
1918
- }
1919
- for (const name of names) {
1920
- try {
1921
- texts.push(readFileSync(join(dir, name), "utf8"));
1922
- } catch {
1923
- /* unreadable */
1924
- }
1925
- }
1926
- }
1927
- return envRedactionValues(texts);
1928
- }
1929
-
1930
- /** 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. */
1931
- verifyPreviewNote(sessionId, previews, secretNames = [], artifactsDir = null, { signedIn = false, finalStateFile = null, services = null, readOnly = false, endpoints = [], external = [], restartNotes = [], apiAuth = false } = {}) {
1932
- const runner = this.services.get(sessionId);
1933
- const apiNames = new Set((services || []).filter((svc) => svc.kind === "api").map((svc) => svc.name));
1934
- const webPreviews = previews.filter((p) => !apiNames.has(p.name));
1935
- const lines = webPreviews.map((p) => {
1936
- const logPath = runner && !p.adopted ? join(runner.logDir, `${p.name}.log`) : null;
1937
- return `- ${p.repo}${p.name && p.name !== p.repo ? ` (${p.name})` : ""}: ${p.url}${p.adopted ? " (your already-running dev server)" : ""}${logPath ? ` · logs: ${logPath}` : ""}`;
1938
- });
1939
- const parts = ["", "", "This is a verification turn: do not modify files, do not commit."];
1940
- if (lines.length > 0) {
1941
- parts.push(`Dev services running for this session:\n${lines.join("\n")}`);
1942
- parts.push(
1943
- "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. " +
1944
- "Tail the service logs with the Read/Bash tools if a page fails to load.",
1945
- );
1946
- }
1947
- const apiServices = (services || []).filter((svc) => svc.kind === "api");
1948
- if (apiServices.length > 0) {
1949
- const apiLines = apiServices.map((svc) => {
1950
- const logPath = runner && !svc.adopted ? join(runner.logDir, `${svc.name}.log`) : null;
1951
- 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}` : ""}`;
1952
- });
1953
- parts.push(
1954
- `API services (no UI):\n${apiLines.join("\n")}\n` +
1955
- `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. ` +
1956
- `Resolve real paths from the OpenAPI spec (or the router files) before calling. ` +
1957
- (readOnly
1958
- ? "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`. "
1959
- : "Writes are allowed in this run — prefer creating throwaway records and say what you created in the report. ") +
1960
- (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. ") +
1961
- (endpoints.length ? `Endpoints the repo asks you to cover: ${endpoints.join(", ")}. ` : "") +
1962
- (lines.length === 0 ? "There is no web UI in this run — no recording is needed; the transcript and the report are the evidence." : ""),
1963
- );
1964
- }
1965
- if (lines.length === 0 && apiServices.length === 0) {
1966
- parts.push(`Dev services running for this session:\n${previews.map((p) => `- ${p.repo}: ${p.url}`).join("\n") || "- (none)"}`);
1967
- }
1968
- if (external.length > 0) parts.push(`External origins the app talks to (allowed for the browser and http_request): ${external.join(", ")}.`);
1969
- if (restartNotes.length > 0) parts.push(restartNotes.join(" "));
1970
- if (artifactsDir) {
1971
- // The MCP resolves a relative `filename` against the CLIENT
1972
- // workspace (the repo worktree), where it would be discarded with
1973
- // the turn — only absolute paths under the output dir (or no
1974
- // filename at all) reach the evidence dir.
1975
- parts.push(
1976
- `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.`,
1977
- );
1978
- }
1979
- if (secretNames.length > 0) {
1980
- parts.push(
1981
- `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.`,
1982
- );
1983
- }
1984
- if (signedIn && finalStateFile) {
1985
- parts.push(
1986
- `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. ` +
1987
- `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.`,
1988
- );
1989
- } else {
1990
- parts.push(
1991
- "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.",
1992
- );
1993
- }
1994
- 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.");
1995
- return parts.join("\n\n").replace(/^\n\n\n\n/, "\n\n");
1996
- }
1997
-
1998
- /**
1999
- * After a verify turn: sweep the evidence dir (the agent may have
2000
- * forgotten the recording), upload every file, then post the report
2001
- * with URLs in place of paths (redacted: nothing from the injected
2002
- * sign-in may leak). No report from the agent → `blocked` with the swept
2003
- * evidence; a report with zero checks → `blocked` / `no_report` (the
2004
- * payload builder). A turn CANCELLED before any report skips the uploads
2005
- * altogether (nobody asked for that footage). `evidence: { failed, kept }`
2006
- * tells the Server how many uploads failed and where the files still are
2007
- * (`bridge.verify.evidence.retry` re-uploads them). Then, when a saved
2008
- * sign-in was injected and the agent left its final `browser_storage_state`
2009
- * export, refresh the record (CAS on the version we read). The dir is
2010
- * deleted only when everything landed; otherwise it stays for the retry /
2011
- * `kai-bridge doctor`.
2012
- */
2013
- async finishVerification(turn, verify, res, cancelled) {
2014
- const { turnId } = turn;
2015
- await this.postVerifyStage(turnId, "saving");
2016
- const redact = verify.redact?.size ? (v) => redactDeep(v, verify.redact) : (v) => v;
2017
- const report = verify.report ? redact(verify.report) : null;
2018
- // Verify turns never push, so the Server cannot derive the tested heads
2019
- // from `changes[]` — stamp them here (repo key → HEAD of the checkout).
2020
- // Companions booted alongside carry `role: "companion"` (their HEAD is
2021
- // the code the API under test actually served).
2022
- const revisions = [
2023
- ...(verify.bound || [])
2024
- .map((b) => ({ repo: b.key, commit: currentHead(b.cwd) }))
2025
- .filter((r) => r.repo && r.commit),
2026
- ...(verify.companionRevisions || []),
2027
- ];
2028
- const fallbackReason = cancelled
2029
- ? "The verification was cancelled before Kai filed a report."
2030
- : res.code !== 0
2031
- ? `The verification turn failed${res.lastError ? `: ${res.lastError.replace(/^acp-runner: /, "").slice(0, 300)}` : ""}.`
2032
- : "Kai finished without filing a verification report";
2033
- let uploads = [];
2034
- let uploadFailures = 0;
2035
- if (cancelled && !report) {
2036
- this.log("info", "verify.cancelled.no_uploads", { turnId });
2037
- } else {
2038
- const swept = await this.uploadSweptEvidence(turnId, verify.artifactsDir, { report, redact: verify.redact, workDir: verify.workDir, signedIn: !!verify.storageStateFile });
2039
- uploads = swept.uploads;
2040
- uploadFailures = swept.failed;
2041
- }
2042
- const evidence = { failed: uploadFailures, ...(uploadFailures > 0 ? { kept: verify.artifactsDir } : {}) };
2043
- const payload = redact({ ...buildVerificationPayload(report, uploads, { fallbackReason, cancelled, evidence, readOnly: typeof verify.readOnly === "boolean" ? verify.readOnly : null }), revisions });
2044
- let reported = false;
2045
- try {
2046
- await this.api.turnVerification(turnId, payload, {
2047
- onRetry: (err, attempt, delay) => this.log("warn", "verify.report.retry", { turnId, attempt, nextInMs: delay, error: err.message }),
2048
- });
2049
- reported = true;
2050
- } catch (err) {
2051
- this.log("error", "verify.report.failed", { turnId, error: err.message });
2052
- }
2053
- this.log("info", "verify.reported", { turnId, status: payload.status, blockedCode: payload.blockedCode, checks: payload.checks.length, artifacts: uploads.length, uploadFailures, reported });
2054
- // The final-state export lives in the evidence dir: refresh the record
2055
- // from it BEFORE the dir is deleted.
2056
- await this.refreshPreviewLogin(turn, verify);
2057
- if ((reported && uploadFailures === 0) || (cancelled && !report)) rmSync(verify.artifactsDir, { recursive: true, force: true });
2058
- else this.log("warn", "verify.artifacts.kept", { turnId, dir: verify.artifactsDir });
2059
- return { status: payload.status };
2060
- }
2061
-
2062
- /**
2063
- * A preview that Verify booted for itself is released once the run has
2064
- * settled — a manual preview keeps running until the user stops it. A
2065
- * `failed` run with fix attempts left keeps the app up: the fix turn's
2066
- * auto re-verify is seconds away and the Server restarts changed services.
2067
- */
2068
- async settleVerifyPreview(turn, status) {
2069
- const sessionId = turn?.sessionId;
2070
- if (!sessionId || !this.verifyOwnedPreviews?.has(sessionId)) return false;
2071
- if (this.manualPreviews?.has(sessionId)) return false;
2072
- const loop = turn.loop && typeof turn.loop === "object" ? turn.loop : null;
2073
- const retryPending = status === "failed" && loop && Number(loop.attempt ?? 0) < Number(loop.max ?? 0);
2074
- if (retryPending) return false;
2075
- this.log("info", "verify.preview.released", { sessionId, status });
2076
- await this.previewStop({ sessionId });
2077
- return true;
2078
- }
2079
-
2080
- /**
2081
- * Sweep an evidence dir, union it with the report's declared artifacts,
2082
- * redact the HTTP transcript in place, upload everything. Resolves
2083
- * `{ uploads: [{ artifact, url }], failed }`. Trace archives are dropped
2084
- * from signed-in runs (they contain every request with its cookies).
2085
- */
2086
- async uploadSweptEvidence(turnId, artifactsDir, { report = null, redact = null, workDir = null, signedIn = false } = {}) {
2087
- const swept = sweepArtifacts(artifactsDir).filter((p) => !(signedIn && /\.zip$/i.test(p)));
2088
- const artifacts = unionArtifacts(report, swept, { outputDir: artifactsDir, workDir }).filter((a) => !(signedIn && a.kind === "trace"));
2089
- const uploads = [];
2090
- let failed = 0;
2091
- for (const artifact of artifacts) {
2092
- if (artifact.kind === "requests") redactJsonlFile(artifact.path, redact);
2093
- try {
2094
- const { url } = await this.api.uploadArtifact(turnId, artifact.path, artifact.contentType, {
2095
- onRetry: (err, attempt, delay) => this.log("warn", "verify.upload.retry", { turnId, file: artifact.path, attempt, nextInMs: delay, error: err.message }),
2096
- });
2097
- uploads.push({ artifact, url });
2098
- } catch (err) {
2099
- failed += 1;
2100
- this.log("warn", "verify.upload.failed", { turnId, file: artifact.path, error: err.message });
2101
- }
2102
- }
2103
- return { uploads, failed, swept: swept.length };
2104
- }
2105
-
2106
- /**
2107
- * `bridge.verify.evidence.retry { turnId }`: the evidence dir kept after
2108
- * failed uploads is swept and uploaded again; the Server unions the
2109
- * artifacts by URL (`PUT …/verification/artifacts`) and clears
2110
- * `evidenceMissing`. The dir goes once everything landed.
2111
- */
2112
- async retryEvidence({ commandId, turnId }) {
2113
- const ack = (payload) => (commandId ? this.api.commandAck(commandId, payload).catch((err) => this.log("warn", "verify.evidence.ack.failed", { turnId, error: err?.message })) : Promise.resolve());
2114
- if (!turnId) return ack({ ok: false, error: "evidence retry without a turnId" });
2115
- const dir = join(this.kaiHome, "artifacts", String(turnId).replace(/[^\w.-]/g, "_"));
2116
- if (!existsSync(dir)) {
2117
- this.log("warn", "verify.evidence.retry.missing", { turnId, dir });
2118
- return ack({ ok: false, error: "The evidence for this run is no longer on this machine." });
2119
- }
2120
- const { uploads, failed, swept } = await this.uploadSweptEvidence(turnId, dir);
2121
- 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 } : {}) }));
2122
- const evidence = { failed, ...(failed > 0 ? { kept: dir } : {}) };
2123
- try {
2124
- await this.api.putVerificationArtifacts(turnId, { artifacts, evidence });
2125
- } catch (err) {
2126
- this.log("error", "verify.evidence.retry.failed", { turnId, error: err?.message });
2127
- return ack({ ok: false, error: err?.message || String(err), uploaded: artifacts.length, failed });
2128
- }
2129
- this.log("info", "verify.evidence.retried", { turnId, swept, uploaded: artifacts.length, failed });
2130
- if (failed === 0) rmSync(dir, { recursive: true, force: true });
2131
- return ack({ ok: true, uploaded: artifacts.length, failed });
2132
- }
2133
-
2134
- /**
2135
- * The agent's final `browser_storage_state` export (cookies + localStorage
2136
- * — what the app may have rotated during the run) merged over the probe
2137
- * export's IndexedDB, filtered to the preview origins, mapped BACK onto
2138
- * the record's captured origins, and PUT with the version we read. 409 =
2139
- * another run refreshed first → ours is discarded. Only for injected
2140
- * records (a dev.yaml command owns its own state).
2141
- */
2142
- async refreshPreviewLogin(turn, verify) {
2143
- const { turnId } = turn;
2144
- const record = verify.injected?.record;
2145
- if (!record?.id || verify.injected.source !== "record" || !verify.finalStateFile) return;
2146
- let final;
2147
- try {
2148
- if (!existsSync(verify.finalStateFile)) {
2149
- this.log("info", "verify.login.refresh.skipped", { turnId, reason: "no final state" });
2150
- return;
2151
- }
2152
- final = JSON.parse(readFileSync(verify.finalStateFile, "utf8"));
2153
- } catch (err) {
2154
- this.log("warn", "verify.login.refresh.unreadable", { turnId, error: err?.message });
2155
- return;
2156
- }
2157
- const merged = mergeFinalState(verify.injected.probeExport, final);
2158
- const { state: filtered, counts } = filterStorageState(merged, verify.stateOrigins ?? verify.previewOrigins);
2159
- const back = rewriteStorageState(filtered, invertOriginMap(verify.injected.originMap));
2160
- if (storageStateIsEmpty(back)) {
2161
- this.log("info", "verify.login.refresh.skipped", { turnId, reason: "empty state" });
2162
- return;
2163
- }
2164
- try {
2165
- await this.api.refreshPreviewLogin(record.id, { storageState: back, basedOnVersion: record.version });
2166
- this.log("info", "verify.login.refreshed", { turnId, recordId: record.id, basedOnVersion: record.version, ...counts });
2167
- } catch (err) {
2168
- 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 });
2169
- }
2170
- }
2171
-
2172
- // ── sign-in windows (bridge.verify.login.*) ────────────────────────
2173
- /**
2174
- * Open a headed Chrome at the preview for the user to sign in; the
2175
- * capture uploads the filtered storage state against `requestId`. Acks
2176
- * `{ ok: true, opened: true }` once the window is up, `{ ok: false,
2177
- * error }` when this machine cannot (updating, no display, preview dead).
2178
- * Idempotent per requestId (the Server replays after sleep).
2179
- */
2180
- async loginStart({ commandId, requestId, sessionId, repoKey, previewNeeded, title, repos }) {
2181
- const ack = (payload) => (commandId ? this.api.commandAck(commandId, payload).catch((err) => this.log("warn", "verify.login.ack.failed", { requestId, error: err?.message })) : Promise.resolve());
2182
- const status = (payload) => this.api.previewLoginStatus(requestId, payload).catch((err) => this.log("warn", "verify.login.status.failed", { requestId, error: err?.message }));
2183
- if (!requestId) {
2184
- await ack({ ok: false, error: "Sign-in request without a requestId." });
2185
- return;
2186
- }
2187
- if (this.logins.has(requestId)) {
2188
- await ack({ ok: true, opened: true, replayed: true });
2189
- return;
2190
- }
2191
- const entry = { requestId, sessionId, repoKey, handle: null, startedAt: Date.now() };
2192
- this.logins.set(requestId, entry);
2193
- try {
2194
- if (this.updating) throw new Error("This machine is updating kai-bridge — try again in a minute.");
2195
- if (!this.displayAvailable()) throw new Error("This machine has no display to open a sign-in window on.");
2196
- const pw = this.loadPlaywright();
2197
- if (!pw?.chromium) throw new Error("Playwright is not installed next to kai-bridge — reinstall @gleapai/kai-bridge.");
2198
- const browser = await this.ensurePreviewBrowser().catch((err) => ({ ok: false, error: err?.message }));
2199
- if (!browser?.ok) throw new Error(browser?.error || "No browser is available on this machine.");
2200
- // Liveness first: booting a cold preview can take minutes, and the
2201
- // Server fails a request nobody acknowledged as "device unreachable".
2202
- // `accepted` moves the request off pending without claiming a window.
2203
- await ack({ ok: true, accepted: true });
2204
- // The preview must be up for the user to sign in to — boot it (or,
2205
- // when it already runs, only re-describe it: previewStart is
2206
- // idempotent on a live runner and we need its URLs either way).
2207
- // `repos`/`title` may be absent on the command; then the request's
2208
- // repo is the whole session and the worktree is looked up by id.
2209
- const sessionRepos = Array.isArray(repos) && repos.length ? repos : [{ key: repoKey }];
2210
- this.log("info", "verify.login.preview", { requestId, sessionId, previewNeeded: !!previewNeeded, live: this.services.has(sessionId) });
2211
- const preview = await this.previewStart({ sessionId, title, repos: sessionRepos });
2212
- if (preview?.status !== "running") throw new Error(preview?.error ? `The preview could not be started: ${preview.error}` : "The preview could not be started.");
2213
- entry.previews = preview.previews || [];
2214
- const runner = this.services.get(sessionId);
2215
- const services = runner?.describeServices?.() ?? (entry.previews || []).map((p) => ({ name: p.name, url: p.url }));
2216
- const previewOrigins = [...new Set([...services.map((s) => normalizeOrigin(s.url)), ...(entry.previews || []).map((p) => normalizeOrigin(p.url))].filter(Boolean))];
2217
- // dev.yaml `external` origins may hold part of the app's own sign-in
2218
- // state — keepable, but never "back on the app" for the detection.
2219
- const external = [...new Set([...new Set((services || []).map((s) => s.repoRoot).filter(Boolean))].flatMap((root) => readDevConfig(root)?.external || []))];
2220
- const url = (entry.previews || []).find((p) => p.repo === repoKey)?.url ?? (entry.previews || [])[0]?.url ?? services[0]?.url;
2221
- if (!url) throw new Error("The preview has no URL to open.");
2222
- // Keep the preview alive while the window is open (the idle timer
2223
- // would otherwise stop the app under the user's nose).
2224
- this.armPreviewIdleTimer(sessionId);
2225
- const handle = await this.captureLogin({
2226
- pw,
2227
- launchOptions: launchOptionsFor({ headless: false, browser: browser.browser === "chrome" ? "chrome" : null }),
2228
- url,
2229
- previewOrigins,
2230
- stateOrigins: [...previewOrigins, ...external],
2231
- services: services.map((s) => ({ name: s.name, origin: normalizeOrigin(s.url) })),
2232
- onStatus: (st, extra) => {
2233
- this.log("info", "verify.login.status", { requestId, status: st, ...(extra?.error ? { error: extra.error } : {}) });
2234
- return status({ status: st, ...(extra?.error ? { error: String(extra.error) } : {}) });
2235
- },
2236
- onSaved: async (payload, counts) => {
2237
- await this.api.uploadPreviewLogin(requestId, payload, {
2238
- tries: 3,
2239
- onRetry: (err, attempt, delay) => this.log("warn", "verify.login.upload.retry", { requestId, attempt, nextInMs: delay, error: err.message }),
2240
- });
2241
- this.log("info", "verify.login.saved", { requestId, repoKey, ...counts, loginPaths: payload.loginPaths.length });
2242
- },
2243
- log: this.log,
2244
- });
2245
- void handle.finished.then((outcome) => {
2246
- this.logins.delete(requestId);
2247
- this.log("info", "verify.login.finished", { requestId, status: outcome?.status, error: outcome?.error });
2248
- this.hello().catch(() => {});
2249
- if ((this.updatePending || this.restartPending) && this.running.size === 0 && this.logins.size === 0) void this.checkForUpdate();
2250
- });
2251
- if (entry.cancelled) {
2252
- // Cancelled while Chrome was still launching (the Server has already
2253
- // settled the request): close the window it just opened, no ack of
2254
- // `opened`, no status (the cancel already answered).
2255
- this.log("info", "verify.login.cancelled.launching", { requestId });
2256
- await handle.cancel();
2257
- await ack({ ok: false, error: "The sign-in was cancelled before the window opened." });
2258
- return;
2259
- }
2260
- entry.handle = handle;
2261
- await ack({ ok: true, opened: true });
2262
- await status({ status: "opened" });
2263
- // Re-announce right away: `activeLogins` is how the dashboard knows a window is open.
2264
- await this.hello().catch((err) => this.log("warn", "verify.login.hello.failed", { requestId, error: err?.message }));
2265
- } catch (err) {
2266
- this.logins.delete(requestId);
2267
- this.log("error", "verify.login.start.failed", { requestId, error: err?.message });
2268
- await ack({ ok: false, error: err?.message || String(err) });
2269
- await status({ status: "failed", error: err?.message || String(err) });
2270
- }
2271
- }
2272
-
2273
- /** "Mark done": export whatever the window holds now (the heuristic may have missed the sign-in). */
2274
- async loginDone({ commandId, requestId }) {
2275
- try {
2276
- const entry = this.logins.get(requestId);
2277
- if (!entry?.handle) throw new Error("No sign-in window is open for this request on this machine.");
2278
- await entry.handle.done();
2279
- if (commandId) await this.api.commandAck(commandId, { ok: true });
2280
- } catch (err) {
2281
- this.log("warn", "verify.login.done.failed", { requestId, error: err?.message });
2282
- if (commandId) await this.api.commandAck(commandId, { ok: false, error: err?.message || String(err) }).catch(() => {});
2283
- }
2284
- }
2285
-
2286
- async loginCancel({ commandId, requestId }) {
2287
- try {
2288
- const entry = this.logins.get(requestId);
2289
- if (!entry) throw new Error("No sign-in window is open for this request on this machine.");
2290
- if (entry.handle) await entry.handle.cancel();
2291
- // Still launching: loginStart closes the window as soon as it is up.
2292
- else entry.cancelled = true;
2293
- if (commandId) await this.api.commandAck(commandId, { ok: true });
2294
- } catch (err) {
2295
- this.log("warn", "verify.login.cancel.failed", { requestId, error: err?.message });
2296
- if (commandId) await this.api.commandAck(commandId, { ok: false, error: err?.message || String(err) }).catch(() => {});
2297
- }
2298
- }
2299
-
2300
- /** Close every sign-in window matching `filter` (session close, shutdown). */
2301
- async teardownLogins(filter, reason) {
2302
- if (!this.logins?.size) return;
2303
- const victims = [...this.logins.values()].filter((l) => {
2304
- try {
2305
- return filter(l);
2306
- } catch {
2307
- return false;
2308
- }
2309
- });
2310
- for (const l of victims) {
2311
- this.log("info", "verify.login.teardown", { requestId: l.requestId, reason });
2312
- this.logins.delete(l.requestId);
2313
- l.cancelled = true; // still launching → loginStart closes it on arrival
2314
- await l.handle?.cancel?.().catch(() => {});
2315
- }
2316
- }
2317
-
2318
1880
  /**
2319
1881
  * Mid-turn steering: forward the message to the running runner's stdin
2320
1882
  * control channel. The runner answers with a `steer` turn event
@@ -2390,6 +1952,43 @@ export class BridgeDaemon {
2390
1952
  return { note: notes.join(""), hasLivePreview: previews.length > 0 };
2391
1953
  }
2392
1954
 
1955
+ /**
1956
+ * Run a git network op with this machine's own credentials; when those are
1957
+ * rejected, retry ONCE with the Server-issued credentials for `repoKey`
1958
+ * (connected repos only — anything else rethrows the original error).
1959
+ */
1960
+ async withGitAuth(repoKey, run) {
1961
+ try {
1962
+ return await run(null);
1963
+ } catch (err) {
1964
+ if (!repoKey || !isGitAuthError(err)) throw err;
1965
+ let authHeader;
1966
+ try {
1967
+ authHeader = await this.gitAuthHeader(repoKey);
1968
+ } catch (credErr) {
1969
+ this.log("warn", "git.auth.unavailable", { repo: repoKey, error: credErr.message });
1970
+ throw err;
1971
+ }
1972
+ this.log("info", "git.auth.fallback", { repo: repoKey });
1973
+ try {
1974
+ return await run(gitAuthEnv(authHeader));
1975
+ } catch (retryErr) {
1976
+ // Stale or revoked: never reuse it for the next attempt.
1977
+ if (isGitAuthError(retryErr)) this.gitAuth.delete(repoKey);
1978
+ throw retryErr;
1979
+ }
1980
+ }
1981
+ }
1982
+
1983
+ async gitAuthHeader(repoKey) {
1984
+ const cached = this.gitAuth.get(repoKey);
1985
+ if (cached && Date.now() - cached.at < GIT_AUTH_TTL_MS) return cached.authHeader;
1986
+ const { authHeader } = await this.api.gitCredentials(repoKey);
1987
+ if (!authHeader) throw new Error("The Server returned no git credentials.");
1988
+ this.gitAuth.set(repoKey, { authHeader, at: Date.now() });
1989
+ return authHeader;
1990
+ }
1991
+
2393
1992
  /**
2394
1993
  * Clone `remote` into the preferred root as `name`. Hardened for
2395
1994
  * unattended runs: no credential prompts (`GIT_TERMINAL_PROMPT=0`,
@@ -2401,7 +2000,7 @@ export class BridgeDaemon {
2401
2000
  async cloneRepo({ commandId, remote, name, repoKey, timeoutMs = CLONE_TIMEOUT_MS }) {
2402
2001
  const root = preferredCloneRoot(this.repoGroups, (this.config.roots || [])[0] ?? defaultRoots()[0]);
2403
2002
  try {
2404
- const target = await cloneRepository({ remote, name, root, repoKey, timeoutMs });
2003
+ const target = await this.withGitAuth(repoKey, (gitEnv) => cloneRepository({ remote, name, root, repoKey, timeoutMs, gitEnv }));
2405
2004
  await this.scanRepos();
2406
2005
  // Ack only after the Server has the new inventory: recovery checks
2407
2006
  // readiness before automatically retrying the blocked session.
@@ -2523,9 +2122,44 @@ export function defaultRealtimeFactory({ config, channel, onEvent, onState }) {
2523
2122
  });
2524
2123
  client.connection?.bind?.("state_change", (s) => onState(s.current));
2525
2124
  const ch = client.subscribe(channel);
2526
- ch.bind_global?.((name, data) => {
2527
- if (typeof name === "string" && name.startsWith("bridge.")) onEvent(name, data);
2528
- });
2125
+ ch.bind_global?.((name, data) => routeRealtimeEvent(name, data, { onEvent, onState }));
2529
2126
  })().catch((err) => onState(`error:${err.message}`));
2530
2127
  return { disconnect: () => client?.disconnect?.() };
2531
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
+ }