@gleapai/kai-bridge 0.2.8 → 0.5.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
@@ -6,12 +6,20 @@
6
6
  // protocol, the same channel family the dashboard uses):
7
7
  // bridge.turn.start { commandId, turnId, sessionId, profileId, repos:[{key, mode, base, carryUncommitted}], ...AgentRunOpts }
8
8
  // bridge.turn.cancel { turnId }
9
+ // bridge.turn.steer { turnId, steer: { id, text } } — inject into the running turn;
10
+ // answered with a {type:"steer", id, outcome} turn event
9
11
  // bridge.repo.clone { commandId, remote, name }
10
12
  // bridge.profile.login{ commandId, profileId }
11
13
  // bridge.rescan {}
14
+ // bridge.verify.login.start { commandId, requestId, sessionId, repoKey, previewNeeded } — headed sign-in window (see preview-login.mjs)
15
+ // bridge.verify.login.done { commandId, requestId } — "Mark done": export now
16
+ // bridge.verify.login.cancel { commandId, requestId }
17
+ // bridge.verify.evidence.retry { commandId?, turnId } — re-upload a kept evidence dir → PUT …/verification/artifacts
18
+ // bridge.session.close { sessionId } — stops the preview, aborts the session's running turns
12
19
 
13
20
  import { spawn } from "node:child_process";
14
- import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
21
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
+ import { execFile } from "node:child_process";
15
23
  import { join, resolve as resolvePath } from "node:path";
16
24
  import { homedir, platform } from "node:os";
17
25
 
@@ -21,16 +29,54 @@ import { KAI_HOME, defaultConfig, loadConfig, saveConfig } from "./config.mjs";
21
29
  import { runTurn } from "./executor.mjs";
22
30
  import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
23
31
  import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
24
- import { discardChanges, collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
25
- import { ServiceRunner, detectDevConfig, previewMcpServer, readDevConfig } from "./preview.mjs";
32
+ import { describeBranchChanges, discardChanges, collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, currentHead, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
33
+ import { ServiceRunner, detectDevConfig, effectiveReload, ensurePreviewBrowser, isPortListening, previewMcpServer, readDevConfig } from "./preview.mjs";
34
+ import { ARTIFACT_MAX_AGE_MS, buildVerificationPayload, createStageHeartbeat, deriveApiAuthHeader, redactJsonlFile, sweepArtifacts, sweepOldArtifacts, unionArtifacts, writeSecretsFile } from "./verify.mjs";
35
+ import { PreviewError, envRedactionValues, previewErrorPayload, toPreviewErrorPayload } from "./preview-errors.mjs";
36
+ import { buildCloneCommand, collectCompanions, prefersSsh, resolveCompanionRemote } from "./companions.mjs";
37
+ import { establishedConnections } from "./ports.mjs";
38
+ import {
39
+ buildOriginMap,
40
+ captureLogin,
41
+ displayAvailable,
42
+ filterStorageState,
43
+ invertOriginMap,
44
+ launchOptionsFor,
45
+ loadPlaywright,
46
+ mergeFinalState,
47
+ normalizeOrigin,
48
+ parseStorageStateOutput,
49
+ preferredStablePort,
50
+ probeLogin,
51
+ redactDeep,
52
+ redactionSet,
53
+ rewriteStorageState,
54
+ storageStateIsEmpty,
55
+ } from "./preview-login.mjs";
26
56
  import { describeHarnesses, installHarness, probeHarnessAuth } from "./harnesses.mjs";
27
57
  import { probeHarnessModels } from "./models.mjs";
28
- import { decideUpdate, fetchLatestVersion, installVersion, installedVersion, isNewer } from "./selfupdate.mjs";
58
+ import { decideRestart, decideUpdate, fetchLatestVersion, installVersion, installedVersion, installedVersionOrNull, isNewer } from "./selfupdate.mjs";
29
59
  import { dirname } from "node:path";
30
60
  import { fileURLToPath } from "node:url";
31
61
 
62
+ const PROBE_TIMEOUT_COLD_BOOT_MS = 60_000;
63
+ const CLONE_TIMEOUT_MS = 3 * 60_000;
64
+ const COMPANION_MAX_DEPTH = 3;
32
65
  const RUNNER_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "runner");
33
66
 
67
+ /** A captured landing URL moved onto the current origin map (null when its origin is not running). */
68
+ function rewriteUrl(url, originMap) {
69
+ const origin = normalizeOrigin(url);
70
+ const to = origin && originMap ? originMap.get(origin) : null;
71
+ if (!to) return null;
72
+ try {
73
+ const u = new URL(String(url));
74
+ return `${to}${u.pathname}${u.search}${u.hash}`;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
34
80
  /** Lock paths held by daemons in THIS process (cross-process uses the file). */
35
81
  const HELD_LOCKS = new Set();
36
82
  const REALTIME_RETRY_MS = 15_000;
@@ -112,7 +158,7 @@ export class BridgeDaemon {
112
158
  this.log = log;
113
159
  this.api = new BridgeApi({ apiBase: config.apiBase, token: config.device?.token });
114
160
  this.realtimeFactory = realtimeFactory;
115
- this.running = new Map(); // turnId → AbortController
161
+ this.running = new Map(); // turnId → { ctrl: AbortController, control?: (obj) => boolean }
116
162
  this.services = new Map(); // sessionId → ServiceRunner (lives across turns)
117
163
  this.repoGroups = [];
118
164
  this.usageByProfile = new Map(); // profileId → plan-usage snapshot (claude only)
@@ -121,6 +167,11 @@ export class BridgeDaemon {
121
167
  this.updateAvailable = null; // newer registry version, when one exists
122
168
  this.updateError = null; // why the last self-update did not apply
123
169
  this.updating = false; // npm is replacing our files — refuse new turns
170
+ this.logins = new Map(); // requestId → { sessionId, repoKey, handle } — open sign-in windows (preview-login.mjs)
171
+ this.verifyLocks = new Map(); // `${userId}:${repoKey}` → promise chain (one probe/refresh of a sign-in at a time)
172
+ this.previewStatusListeners = new Map(); // sessionId → (message) => void — the booting heartbeat of a verify turn
173
+ this.unknownDevKeysLogged = new Set(); // "<repo>:<key>" — unknown dev.yaml keys are logged once
174
+ this.restartPending = false; // files on disk are a different version than the one we loaded; restart when idle
124
175
  }
125
176
 
126
177
  async scanRepos() {
@@ -146,6 +197,10 @@ export class BridgeDaemon {
146
197
  updateAvailable: this.updateAvailable || null,
147
198
  updateError: this.updateError || null,
148
199
  autoUpdate: this.config.autoUpdate !== false,
200
+ // Verify sign-in: can this machine open a headed Chrome window, and
201
+ // which sign-in requests are open right now (replayed after sleep).
202
+ capabilities: { display: this.displayAvailable() },
203
+ activeLogins: [...this.logins.keys()],
149
204
  });
150
205
  }
151
206
 
@@ -164,6 +219,12 @@ export class BridgeDaemon {
164
219
  // to report its turns. Tell the server before doing anything else,
165
220
  // so those sessions settle instead of spinning.
166
221
  await this.reportInterruptedTurns();
222
+ // Dev servers a killed daemon left behind would hold the declared ports
223
+ // (→ port_busy on the next preview) — end them; then drop evidence dirs
224
+ // nobody retried for two days.
225
+ this.killOrphanedServices();
226
+ const swept = sweepOldArtifacts(join(this.kaiHome, "artifacts"), { maxAgeMs: ARTIFACT_MAX_AGE_MS });
227
+ if (swept.length) this.log("info", "artifacts.swept", { count: swept.length });
167
228
  await this.scanRepos();
168
229
  // The first hello must not kill the daemon: the server may be
169
230
  // restarting (deploys, local nodemon) — retry with backoff instead
@@ -229,6 +290,18 @@ export class BridgeDaemon {
229
290
  if (this.stopped || this.updating) return;
230
291
  // Tests and embedded hosts (the Desktop app ships its own copy).
231
292
  if (process.env.KAI_BRIDGE_NO_SELF_UPDATE === "1") return;
293
+ // Someone ran `npm i -g` (or an older daemon's update landed) without
294
+ // restarting us: the files on disk are not the code we run. Restart
295
+ // into them as soon as no turn is running — BEFORE asking the registry,
296
+ // which needs network this check must not depend on.
297
+ const onDisk = installedVersionOrNull();
298
+ const restart = decideRestart({ loaded: VERSION, installed: onDisk, running: this.running.size + this.logins.size + this.services.size });
299
+ this.restartPending = restart.action === "defer";
300
+ if (restart.action === "restart") {
301
+ this.log("info", "update.restart.stale", { loaded: VERSION, installed: onDisk });
302
+ this.restartForUpdate();
303
+ return;
304
+ }
232
305
  let latest;
233
306
  try {
234
307
  latest = await fetchLatestVersion();
@@ -240,7 +313,10 @@ export class BridgeDaemon {
240
313
  const decision = decideUpdate({
241
314
  current: VERSION,
242
315
  latest,
243
- running: this.running.size,
316
+ // An open sign-in window is work in flight too: replacing our files
317
+ // under a headed Chrome the user is typing into loses the capture —
318
+ // and so is a live preview someone may be looking at right now.
319
+ running: this.running.size + this.logins.size + this.services.size,
244
320
  autoUpdate: this.config.autoUpdate !== false,
245
321
  lastAttempt: this.config.selfUpdate?.lastAttempt ?? null,
246
322
  });
@@ -276,12 +352,12 @@ export class BridgeDaemon {
276
352
  return;
277
353
  }
278
354
  this.log("info", "update.installed", { version, restarting: true });
279
- this.restartForUpdate();
355
+ await this.restartForUpdate();
280
356
  }
281
357
 
282
358
  /** Hand the machine to the new version: the service restarts us. */
283
- restartForUpdate() {
284
- this.stop();
359
+ async restartForUpdate() {
360
+ await this.stop();
285
361
  if (platform() === "win32") {
286
362
  // Task Scheduler only starts at logon — respawn ourselves instead.
287
363
  const child = spawn(process.execPath, [process.argv[1], "start"], { detached: true, stdio: "ignore", windowsHide: true });
@@ -370,6 +446,13 @@ export class BridgeDaemon {
370
446
  }
371
447
  }
372
448
 
449
+ /**
450
+ * Shut down: abort turns, stop every preview (telling the dashboard why —
451
+ * `stopped` / `daemon_restarted`, so the card offers Start instead of
452
+ * showing dead links), close sign-in windows. Resolves once the preview
453
+ * reports have been given a bounded chance to land (the self-update
454
+ * restart awaits it; sync callers may ignore the promise).
455
+ */
373
456
  stop() {
374
457
  this.stopped = true;
375
458
  clearInterval(this.heartbeat);
@@ -377,10 +460,19 @@ export class BridgeDaemon {
377
460
  clearInterval(this.modelsTimer);
378
461
  clearInterval(this.updateTimer);
379
462
  if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
380
- for (const ctrl of this.running.values()) ctrl.abort();
381
- for (const runner of this.services.values()) runner.stopAll();
463
+ for (const entry of this.running.values()) entry.ctrl.abort();
464
+ const reports = [];
465
+ for (const [sessionId, runner] of this.services) {
466
+ runner.stopAll();
467
+ this.clearPreviewIdleTimer(sessionId);
468
+ reports.push(this.api.sessionPreview(sessionId, { status: "stopped", urls: [], previews: [], errorCode: "daemon_restarted", error: "kai-bridge restarted — start the preview again." }).catch(() => {}));
469
+ }
470
+ this.services.clear();
471
+ this.writePreviewPids([]);
472
+ void this.teardownLogins(() => true, "daemon stopping");
382
473
  this.realtime?.disconnect?.();
383
474
  this.releaseLock();
475
+ return Promise.race([Promise.allSettled(reports), new Promise((r) => setTimeout(r, 3_000).unref?.())]).then(() => undefined);
384
476
  }
385
477
 
386
478
  // ── single instance ────────────────────────────────────────────────
@@ -435,38 +527,57 @@ export class BridgeDaemon {
435
527
  return join(this.kaiHome, "state", "inflight.json");
436
528
  }
437
529
 
530
+ /** `[{ turnId, sessionId?, agent?, artifactsDir? }]` — pre-0.5.0 files held bare ids. */
438
531
  readInflight() {
439
532
  try {
440
- return JSON.parse(readFileSync(this.inflightPath, "utf8"));
533
+ const raw = JSON.parse(readFileSync(this.inflightPath, "utf8"));
534
+ return (Array.isArray(raw) ? raw : []).map((e) => (typeof e === "string" ? { turnId: e } : e)).filter((e) => e && typeof e.turnId === "string");
441
535
  } catch {
442
536
  return [];
443
537
  }
444
538
  }
445
539
 
446
- writeInflight(ids) {
540
+ writeInflight(entries) {
447
541
  try {
448
542
  mkdirSync(join(this.kaiHome, "state"), { recursive: true });
449
- writeFileSync(this.inflightPath, JSON.stringify(ids));
543
+ writeFileSync(this.inflightPath, JSON.stringify(entries));
450
544
  } catch {
451
545
  /* best effort */
452
546
  }
453
547
  }
454
548
 
455
- rememberInflight(turnId) {
456
- this.writeInflight([...new Set([...this.readInflight(), turnId])]);
549
+ rememberInflight(turnId, meta = {}) {
550
+ const rest = this.readInflight().filter((e) => e.turnId !== turnId);
551
+ this.writeInflight([...rest, { turnId, ...meta }]);
457
552
  }
458
553
 
459
554
  forgetInflight(turnId) {
460
- this.writeInflight(this.readInflight().filter((id) => id !== turnId));
555
+ this.writeInflight(this.readInflight().filter((e) => e.turnId !== turnId));
461
556
  }
462
557
 
463
- /** Turns this machine was running when it was killed — report them dead. */
558
+ /**
559
+ * Turns this machine was running when it was killed — report them dead.
560
+ * A verify turn also gets a `blocked` report ("kai-bridge restarted
561
+ * mid-run") carrying whatever evidence the browser had written, so the
562
+ * card shows the partial recording instead of spinning.
563
+ */
464
564
  async reportInterruptedTurns() {
465
- const ids = this.readInflight();
466
- if (!ids.length) return;
565
+ const entries = this.readInflight();
566
+ if (!entries.length) return;
467
567
  this.writeInflight([]);
468
- for (const turnId of ids) {
469
- this.log("warn", "turn.interrupted", { turnId });
568
+ for (const entry of entries) {
569
+ const { turnId } = entry;
570
+ this.log("warn", "turn.interrupted", { turnId, agent: entry.agent });
571
+ if (entry.agent === "kai-verifier") {
572
+ const uploads = entry.artifactsDir ? await this.uploadSweptEvidence(turnId, entry.artifactsDir) : { uploads: [], failed: 0 };
573
+ await this.api
574
+ .turnVerification(turnId, {
575
+ ...buildVerificationPayload(null, uploads.uploads, { fallbackReason: "kai-bridge restarted mid-run", evidence: { failed: uploads.failed, ...(uploads.failed && entry.artifactsDir ? { kept: entry.artifactsDir } : {}) } }),
576
+ blockedCode: "other",
577
+ })
578
+ .catch((err) => this.log("warn", "verify.interrupted.report.failed", { turnId, error: err?.message }));
579
+ if (entry.artifactsDir && uploads.failed === 0) rmSync(entry.artifactsDir, { recursive: true, force: true });
580
+ }
470
581
  await this.api
471
582
  .turnResult(turnId, {
472
583
  status: "failed",
@@ -476,6 +587,70 @@ export class BridgeDaemon {
476
587
  }
477
588
  }
478
589
 
590
+ // ── preview processes across restarts ─────────────────────────────
591
+ get previewPidsPath() {
592
+ return join(this.kaiHome, "state", "preview-pids.json");
593
+ }
594
+
595
+ readPreviewPids() {
596
+ try {
597
+ const raw = JSON.parse(readFileSync(this.previewPidsPath, "utf8"));
598
+ return Array.isArray(raw) ? raw.filter((e) => e && Number.isInteger(e.pid)) : [];
599
+ } catch {
600
+ return [];
601
+ }
602
+ }
603
+
604
+ writePreviewPids(entries) {
605
+ try {
606
+ mkdirSync(join(this.kaiHome, "state"), { recursive: true });
607
+ writeFileSync(this.previewPidsPath, JSON.stringify(entries));
608
+ } catch {
609
+ /* best effort */
610
+ }
611
+ }
612
+
613
+ /** ServiceRunner hook: every dev-server pid is persisted while it lives. */
614
+ trackServicePid(sessionId, name, pid, op) {
615
+ if (!Number.isInteger(pid)) return;
616
+ const rest = this.readPreviewPids().filter((e) => e.pid !== pid);
617
+ this.writePreviewPids(op === "add" ? [...rest, { pid, name, sessionId, startedAt: new Date().toISOString() }] : rest);
618
+ }
619
+
620
+ /**
621
+ * Dev servers from a previous daemon life (crash, kill -9) are still
622
+ * bound to their ports: end their process groups (Windows: the tree) and
623
+ * forget them. Pids are checked for liveness first; a reused pid that no
624
+ * longer looks like our service is left alone.
625
+ */
626
+ killOrphanedServices({ kill = process.kill, platform = process.platform } = {}) {
627
+ const entries = this.readPreviewPids();
628
+ this.writePreviewPids([]);
629
+ let killed = 0;
630
+ for (const e of entries) {
631
+ try {
632
+ kill(e.pid, 0); // alive?
633
+ } catch {
634
+ continue;
635
+ }
636
+ try {
637
+ if (platform === "win32") spawn("taskkill", ["/PID", String(e.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => {});
638
+ else {
639
+ try {
640
+ kill(-e.pid, "SIGTERM");
641
+ } catch {
642
+ kill(e.pid, "SIGTERM");
643
+ }
644
+ }
645
+ killed += 1;
646
+ this.log("warn", "preview.orphan.killed", { pid: e.pid, name: e.name, sessionId: e.sessionId });
647
+ } catch {
648
+ /* already gone */
649
+ }
650
+ }
651
+ return killed;
652
+ }
653
+
479
654
  /**
480
655
  * The realtime client retries a refused connection forever, but two
481
656
  * states are terminal: six failed reconnects of an ESTABLISHED socket
@@ -526,11 +701,27 @@ export class BridgeDaemon {
526
701
  }
527
702
  try {
528
703
  const pending = await this.api.pendingTurns();
704
+ // Cancels the server settled while we were away (reaper / watchdog):
705
+ // abort the orphaned run instead of reporting into a turn that ended,
706
+ // and never "recover" a turn that was cancelled in the same breath.
707
+ const cancelled = new Set((pending?.cancelledTurnIds || []).map(String));
708
+ for (const turnId of cancelled) {
709
+ const run = this.running.get(turnId);
710
+ if (!run) continue;
711
+ this.log("info", "turn.cancel.replayed", { turnId });
712
+ run.ctrl.abort();
713
+ }
529
714
  for (const turn of pending?.turns || []) {
530
- if (this.running.has(turn.turnId)) continue;
715
+ if (this.running.has(turn.turnId) || cancelled.has(String(turn.turnId))) continue;
531
716
  this.log("info", "turn.recovered", { turnId: turn.turnId });
532
717
  void this.startTurn(turn).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
533
718
  }
719
+ // Sign-in requests published while we were away: open their windows now.
720
+ for (const req of pending?.loginRequests || []) {
721
+ if (!req?.requestId || this.logins.has(req.requestId)) continue;
722
+ this.log("info", "verify.login.recovered", { requestId: req.requestId });
723
+ void this.loginStart(req).catch((err) => this.log("error", "verify.login.recover.failed", { error: err.message }));
724
+ }
534
725
  } catch (err) {
535
726
  // Older server without the endpoint: the server-side sweep still
536
727
  // fails orphaned turns, so this is a nice-to-have, not a must.
@@ -591,8 +782,10 @@ export class BridgeDaemon {
591
782
  }
592
783
  return this.startTurn(data);
593
784
  case "bridge.turn.cancel":
594
- this.running.get(data.turnId)?.abort();
785
+ this.running.get(data.turnId)?.ctrl.abort();
595
786
  return;
787
+ case "bridge.turn.steer":
788
+ return this.steerTurn(data);
596
789
  case "bridge.rescan":
597
790
  await this.scanRepos();
598
791
  // A rescan is the user's "look again" — refresh the model lists too
@@ -608,11 +801,35 @@ export class BridgeDaemon {
608
801
  case "bridge.harness.install":
609
802
  return this.harnessInstall(data);
610
803
  case "bridge.session.close":
804
+ // A closed session has no turn left to run: abort ours for it (the
805
+ // Server has already settled them; the runner just stops burning).
806
+ for (const [turnId, entry] of this.running) {
807
+ if (entry.sessionId === data.sessionId) {
808
+ this.log("info", "turn.abort.session_closed", { turnId, sessionId: data.sessionId });
809
+ entry.ctrl.abort();
810
+ }
811
+ }
611
812
  this.services.get(data.sessionId)?.stopAll();
612
813
  this.services.delete(data.sessionId);
613
814
  this.clearPreviewIdleTimer(data.sessionId);
815
+ // A sign-in window opened for this session has nothing to sign in to any more.
816
+ await this.teardownLogins((l) => l.sessionId === data.sessionId, "session closed");
817
+ // Pre-0.4.0 daemons kept a browser profile per session; drop leftovers.
818
+ rmSync(join(this.kaiHome, "browser", String(data.sessionId).replace(/[^\w.-]/g, "_")), { recursive: true, force: true });
614
819
  return;
820
+ case "bridge.verify.login.start":
821
+ return this.loginStart(data);
822
+ case "bridge.verify.login.done":
823
+ return this.loginDone(data);
824
+ case "bridge.verify.login.cancel":
825
+ return this.loginCancel(data);
826
+ case "bridge.verify.evidence.retry":
827
+ return this.retryEvidence(data);
615
828
  case "bridge.preview.start":
829
+ // A preview the USER started stays up until they stop it (or it
830
+ // idles out); one that Verify booted is released after the run.
831
+ (this.manualPreviews ??= new Set()).add(data.sessionId);
832
+ this.verifyOwnedPreviews?.delete(data.sessionId);
616
833
  return this.previewStart(data);
617
834
  case "bridge.preview.stop":
618
835
  return this.previewStop(data);
@@ -632,148 +849,270 @@ export class BridgeDaemon {
632
849
  * services inside the session's EXISTING worktrees. Resolves paths by
633
850
  * existence only — materializing checkouts is the turn path's job, so
634
851
  * a pruned worktree is an error, not a re-clone.
852
+ *
853
+ * Every failure is a structured error (`previewErrorPayload`): code +
854
+ * kind + repo/service + detail, classified from the whole log tail BEFORE
855
+ * truncation. `companionRemotes` (Server-resolved `{ key: remote }`) is
856
+ * how non-github companions get cloned; `note` survives onto error writes.
635
857
  */
636
- async previewStart({ sessionId, title, repos }) {
637
- const report = (payload) =>
638
- this.api.sessionPreview(sessionId, payload).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
858
+ async previewStart({ sessionId, title, repos, companionRemotes = null }) {
859
+ let lastNote = null;
860
+ const skipped = [];
861
+ // Every report is also RETURNED: the verify turn boots the preview
862
+ // through this same path and needs the final status + preview URLs.
863
+ const report = async (payload) => {
864
+ if (payload.note) lastNote = payload.note;
865
+ const body = payload.status === "error" ? { ...payload, urls: payload.urls ?? [], previews: payload.previews ?? [], ...(lastNote && !payload.note ? { note: lastNote } : {}), ...(skipped.length && !payload.skipped ? { skipped } : {}) } : payload;
866
+ await this.api.sessionPreview(sessionId, body).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
867
+ return body;
868
+ };
869
+ const fail = (err, ctx = {}) => report(toPreviewErrorPayload(err, { ...ctx, skipped }));
639
870
  await report({ status: "starting" });
871
+ let runner = null;
640
872
  try {
641
- // Pass 1 — resolve every session repo (checkout, env, config) and
642
- // collect the companion repos their configs ask for, before booting
643
- // anything: companions must boot FIRST so `${port:x}` cross-refs
644
- // resolve in the shared per-session port map.
873
+ // Pass 1 — resolve every session repo (checkout, env, config).
645
874
  const resolved = [];
646
- const companionWants = new Map(); // repo key → { optional }
647
875
  for (const r of repos || []) {
648
876
  const group = this.repoGroups.find((g) => g.key === r.key);
649
877
  if (!group) {
650
- await report({ status: "error", error: `Repository ${r.key} is not checked out on this device.` });
651
- return;
878
+ return fail(new PreviewError(`Repository ${r.key} is not checked out on this device.`, { code: "companion_missing", repo: r.key }), { repo: r.key });
652
879
  }
653
880
  const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
654
- const cwd = mode === "local" ? group.primary.path : worktreePath(this.kaiHome, group.name, sessionSlug(sessionId, title));
655
- if (!existsSync(cwd)) {
656
- await report({
657
- status: "error",
658
- error: `The workspace for this session is gone on this device — send Kai a message to recreate it, then start the preview again.`,
659
- });
660
- return;
881
+ const cwd = mode === "local" ? group.primary.path : this.findSessionWorktree(group.name, sessionId, title);
882
+ if (!cwd || !existsSync(cwd)) {
883
+ return fail(new PreviewError(`The workspace for this session is gone on this device — send Kai a message to recreate it, then start the preview again.`, { code: "other", repo: r.key }), { repo: r.key });
661
884
  }
662
885
  // Gitignored env files never reach a fresh worktree — copy the
663
886
  // primary checkout's so env-dependent apps (the dashboard itself)
664
- // don't boot blank. Never overwrites existing files.
887
+ // don't boot blank (per service cwd too — ServiceRunner). Never
888
+ // overwrites existing files.
665
889
  if (mode !== "local") {
666
890
  const copied = copyPrimaryEnvFiles(group.primary.path, cwd);
667
891
  if (copied.length) this.log("info", "preview.env.copied", { cwd, copied });
668
892
  }
669
- // Config resolution order: this worktree's own config, then the
670
- // PRIMARY checkout's (a repo verified by the setup agent has its
671
- // dev.yaml on an unmerged config PR — session worktrees cut from
672
- // the base branch don't carry it yet), then the package.json
673
- // heuristic. Closes the verified-before-config-merge window.
674
- const config =
675
- readDevConfig(cwd) ??
676
- (cwd !== group.primary.path ? readDevConfig(group.primary.path) : null) ??
677
- detectDevConfig(cwd);
678
- if (!config) continue;
679
- if (config.error) {
680
- await report({ status: "error", error: config.error });
681
- return;
893
+ const config = this.resolveDevConfig(cwd, group, r.key);
894
+ if (!config) {
895
+ skipped.push({ repo: r.key, reason: "no_dev_config" });
896
+ continue;
682
897
  }
683
- for (const c of config.companions || []) {
684
- if ((repos || []).some((sr) => sr.key === c.repo)) continue; // already part of the session
685
- const prev = companionWants.get(c.repo);
686
- // Required by any config → required overall.
687
- companionWants.set(c.repo, { optional: (prev ? prev.optional : true) && c.optional });
898
+ if (config.needsConfig) {
899
+ skipped.push({ repo: r.key, reason: "monorepo", hint: config.hint });
900
+ continue;
688
901
  }
689
- resolved.push({ key: r.key, cwd, config, mode });
902
+ if (config.error) return fail(new PreviewError(config.error, { code: "no_dev_config", repo: r.key }), { repo: r.key });
903
+ resolved.push({ key: r.key, cwd, config, mode, envSource: mode === "local" ? null : group.primary.path });
690
904
  }
691
905
  if (!resolved.length) {
692
- await report({ status: "error", error: "No dev config found — add .gleap/dev.yaml to the repo (services + preview)." });
693
- return;
906
+ const mono = skipped.find((sk) => sk.reason === "monorepo");
907
+ return fail(
908
+ new PreviewError(
909
+ mono ? `${mono.repo} is a ${mono.hint} — add .gleap/dev.yaml (services + preview) so Kai knows which app to run.` : "No dev config found — add .gleap/dev.yaml to the repo (services + preview).",
910
+ { code: "no_dev_config", repo: skipped[0]?.repo, hint: mono?.hint },
911
+ ),
912
+ );
913
+ }
914
+ if (skipped.length) await report({ status: "starting", note: `Skipped ${skipped.map((sk) => `${sk.repo} (${sk.reason === "monorepo" ? sk.hint : "no dev config"})`).join(", ")}.` });
915
+
916
+ // Pass 2 — companions (BFS, depth ≤ 3): clone when missing (remote
917
+ // from the Server, never guessed for non-github), read their config
918
+ // from the PRIMARY checkout; a companion without a config is
919
+ // `companion_unconfigured` (once — no retry loop).
920
+ const remotes = companionRemotes && typeof companionRemotes === "object" ? companionRemotes : {};
921
+ const { companions } = await collectCompanions({
922
+ roots: resolved.map((r) => ({ key: r.key, config: r.config })),
923
+ maxDepth: COMPANION_MAX_DEPTH,
924
+ loadConfig: async (key) => {
925
+ try {
926
+ const group = await this.ensureCompanionCheckout(key, report, { remotes });
927
+ const config = this.resolveDevConfig(group.primary.path, group, key);
928
+ if (!config || config.needsConfig) return { group, unconfigured: true, hint: config?.hint ?? null };
929
+ if (config.error) return { group, error: config.error };
930
+ return { group, config };
931
+ } catch (err) {
932
+ return { failure: err };
933
+ }
934
+ },
935
+ });
936
+ for (const c of companions) {
937
+ if (c.config) continue;
938
+ if (c.optional) {
939
+ this.log("warn", "preview.companion.skipped", { sessionId, repo: c.key, reason: c.failure?.message || (c.unconfigured ? "unconfigured" : c.error) });
940
+ skipped.push({ repo: c.key, reason: c.unconfigured ? "no_dev_config" : "not_on_device" });
941
+ continue;
942
+ }
943
+ if (c.failure) return fail(c.failure, { repo: c.key });
944
+ if (c.unconfigured) {
945
+ return fail(new PreviewError(`Companion ${c.key} has no .gleap/dev.yaml${c.hint ? ` (${c.hint})` : ""} — configure it so its services can run alongside.`, { code: "companion_unconfigured", repo: c.key, hint: c.hint }), { repo: c.key });
946
+ }
947
+ return fail(new PreviewError(`Companion ${c.key}: ${c.error}`, { code: "no_dev_config", repo: c.key }), { repo: c.key });
694
948
  }
695
- const runner = this.runnerFor(sessionId, (message) => this.log("info", "preview.status", { sessionId, message }));
696
- // Pass 2 — companions: clone when missing, then boot from the
697
- // PRIMARY checkout in local mode, so an already-running dev server
698
- // on the declared port is adopted instead of duplicated.
949
+ const bootable = companions.filter((c) => c.config);
950
+
951
+ runner = this.runnerFor(sessionId);
952
+ // Pass 3 — ports for EVERY config (companions + session repos) before
953
+ // any boot, so `${port:x}` cross-references resolve whatever the order.
954
+ for (const c of bootable) await runner.assignPorts(c.group.primary.path, c.config, { mode: "local", repoKey: c.key });
955
+ for (const r of resolved) await runner.assignPorts(r.cwd, r.config, { mode: r.mode, repoKey: r.key, envSource: r.envSource });
956
+
957
+ // Pass 4 — boot companions (deepest first, local mode: an already
958
+ // running dev server of that repo is adopted), then the session repos
959
+ // (worktree mode boots its own copy: it must serve the changed code).
699
960
  const companionPreviews = [];
700
- for (const [key, { optional }] of companionWants) {
961
+ for (const c of bootable) {
701
962
  try {
702
- const group = await this.ensureCompanionCheckout(key, report);
703
- const config = readDevConfig(group.primary.path) ?? detectDevConfig(group.primary.path);
704
- if (!config) throw new Error(`no dev config — add .gleap/dev.yaml to ${key}`);
705
- if (config.error) throw new Error(config.error);
706
- const started = await this.bootRepoWithConfig(runner, group.primary.path, config, "local");
707
- if (started.preview) companionPreviews.push({ repo: key, ...started.preview });
963
+ const started = await this.bootRepoWithConfig(runner, c.group.primary.path, c.config, "local", c.key);
964
+ if (started.preview) {
965
+ const { adoptedCwd, ...preview } = started.preview;
966
+ companionPreviews.push({ repo: c.key, role: "companion", ...preview, ...this.describeCheckout(adoptedCwd || c.group.primary.path) });
967
+ }
708
968
  } catch (err) {
709
- if (optional) {
710
- this.log("warn", "preview.companion.skipped", { sessionId, repo: key, error: err.message });
969
+ if (c.optional) {
970
+ this.log("warn", "preview.companion.skipped", { sessionId, repo: c.key, error: err.message });
971
+ skipped.push({ repo: c.key, reason: "not_on_device" });
711
972
  continue;
712
973
  }
713
- runner.stopAll();
714
- this.services.delete(sessionId);
715
- await report({ status: "error", error: `Companion ${key}: ${err.message}` });
716
- return;
974
+ throw err;
717
975
  }
718
976
  }
719
- // Pass 3 — the session repos themselves (worktree mode boots its
720
- // own copy on a free port: it must serve the changed code).
721
977
  const previews = [];
722
978
  for (const { key, cwd, config, mode } of resolved) {
723
- try {
724
- const started = await this.bootRepoWithConfig(runner, cwd, config, mode);
725
- if (started.preview) previews.push({ repo: key, ...started.preview });
726
- } catch (err) {
727
- runner.stopAll();
728
- this.services.delete(sessionId);
729
- await report({ status: "error", error: err.message });
730
- return;
979
+ const started = await this.bootRepoWithConfig(runner, cwd, config, mode, key);
980
+ if (started.preview) {
981
+ const { adoptedCwd, ...preview } = started.preview;
982
+ previews.push({ repo: key, ...preview, ...this.describeCheckout(adoptedCwd || cwd) });
731
983
  }
732
984
  }
985
+ const urls = [...previews, ...companionPreviews];
986
+ // The landing page is a session repo's preview (never a companion's).
987
+ const landing = previews[0] ?? companionPreviews[0] ?? null;
733
988
  this.armPreviewIdleTimer(sessionId);
734
- await report({ status: "running", previews: [...previews, ...companionPreviews] });
989
+ return report({ status: "running", previews: urls, urls, landingUrl: landing?.url ?? null, ...(skipped.length ? { skipped } : {}) });
735
990
  } catch (err) {
736
- this.log("error", "preview.start.failed", { sessionId, error: err.message });
737
- await report({ status: "error", error: err.message });
991
+ this.log("error", "preview.start.failed", { sessionId, error: err.message, code: err?.code });
992
+ runner?.stopAll();
993
+ this.services.delete(sessionId);
994
+ this.clearPreviewIdleTimer(sessionId);
995
+ return fail(err);
996
+ }
997
+ }
998
+
999
+ /**
1000
+ * Config resolution order: this worktree's own config, then the PRIMARY
1001
+ * checkout's (a repo verified by the setup agent has its dev.yaml on an
1002
+ * unmerged config PR — session worktrees cut from the base branch don't
1003
+ * carry it yet), then the package.json heuristic (which answers
1004
+ * `{ needsConfig }` for workspace roots). Unknown dev.yaml keys are
1005
+ * logged once per repo. Closes the verified-before-config-merge window.
1006
+ */
1007
+ resolveDevConfig(cwd, group, repoKey) {
1008
+ const config = readDevConfig(cwd) ?? (group && cwd !== group.primary.path ? readDevConfig(group.primary.path) : null) ?? detectDevConfig(cwd);
1009
+ // Lazily created: embedded hosts (and tests) build a daemon without
1010
+ // running the constructor — a missing bookkeeping map must never turn
1011
+ // a bootable preview into "Cannot read properties of undefined".
1012
+ this.unknownDevKeysLogged ??= new Set();
1013
+ for (const key of config?.unknownKeys || []) {
1014
+ const id = `${repoKey}:${key}`;
1015
+ if (this.unknownDevKeysLogged.has(id)) continue;
1016
+ this.unknownDevKeysLogged.add(id);
1017
+ this.log("warn", "preview.config.unknown_key", { repo: repoKey, key });
1018
+ }
1019
+ return config;
1020
+ }
1021
+
1022
+ /** `{ branch, commit, dirty }` of a checkout for the preview's `urls[]` entries (best-effort). */
1023
+ describeCheckout(cwd) {
1024
+ const out = {};
1025
+ try {
1026
+ const branch = currentBranch(cwd);
1027
+ if (branch) out.branch = branch;
1028
+ const commit = currentHead(cwd);
1029
+ if (commit) out.commit = commit;
1030
+ out.dirty = collectChanges(cwd).files.length;
1031
+ } catch {
1032
+ /* not a git checkout */
1033
+ }
1034
+ return out;
1035
+ }
1036
+
1037
+ /**
1038
+ * The session's worktree for a repo: the exact slug when the title is
1039
+ * known; otherwise (sign-in commands carry only the session id) the one
1040
+ * directory under `~/.kai/worktrees/<repo>/` that ends in the session's
1041
+ * id suffix — slugs are `<title>-<last 8 of the id>`, unique per session.
1042
+ */
1043
+ findSessionWorktree(repoName, sessionId, title) {
1044
+ const exact = worktreePath(this.kaiHome, repoName, sessionSlug(sessionId, title));
1045
+ if (existsSync(exact)) return exact;
1046
+ const suffix = `-${String(sessionId || "").slice(-8)}`;
1047
+ if (suffix.length < 2) return exact;
1048
+ try {
1049
+ const dir = join(this.kaiHome, "worktrees", repoName);
1050
+ const hits = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && d.name.endsWith(suffix));
1051
+ return hits.length === 1 ? join(dir, hits[0].name) : exact;
1052
+ } catch {
1053
+ return exact;
738
1054
  }
739
1055
  }
740
1056
 
741
1057
  /**
742
1058
  * Boot one repo's services with an already-resolved config; throws a
743
- * readable error (with the failing service's log tail) when a service
1059
+ * `PreviewError` carrying the classified log line + code when a service
744
1060
  * never becomes ready — a "running" preview must not 404.
745
1061
  */
746
- async bootRepoWithConfig(runner, cwd, config, mode) {
747
- const started = await runner.start(cwd, config, { mode });
1062
+ async bootRepoWithConfig(runner, cwd, config, mode, repoKey = null, opts = {}) {
1063
+ const started = await runner.start(cwd, config, { mode, repoKey, ...opts });
748
1064
  const dead = started.services.find((s) => !s.adopted && !s.ready);
749
1065
  if (dead) {
750
- let tail = "";
751
- try {
752
- const raw = readFileSync(dead.logPath, "utf8");
753
- tail = raw.trim().split("\n").slice(-3).join(" · ").slice(0, 300);
754
- } catch {}
755
- throw new Error(`${dead.name} did not start${tail ? ` — ${tail}` : ""}`);
1066
+ throw new PreviewError(`${dead.name} did not start${dead.error ? ` — ${dead.error}` : ""}`, {
1067
+ code: dead.errorCode || "service_crashed",
1068
+ repo: repoKey,
1069
+ service: dead.name,
1070
+ detail: { ...(dead.errorDetail || {}), logPath: dead.logPath },
1071
+ });
756
1072
  }
757
1073
  return started;
758
1074
  }
759
1075
 
760
1076
  /**
761
- * A companion repo must exist on this machine. Cloned → its group.
762
- * Not cloned → clone it into the preferred root (github.com keys only
763
- * — the key itself is the remote address) and rescan.
1077
+ * A companion repo must exist on this machine. Cloned → its group. Not
1078
+ * cloned → clone it (remote from the Server's `companionRemotes`, or the
1079
+ * key itself for github.com; SSH when the machine's checkouts use SSH)
1080
+ * into the preferred root and rescan. Without a remote the daemon
1081
+ * cannot guess → `companion_missing`; a failed clone →
1082
+ * `companion_clone_failed` with the exact command to run by hand.
764
1083
  */
765
- async ensureCompanionCheckout(key, report) {
1084
+ async ensureCompanionCheckout(key, report, { remotes = {} } = {}) {
766
1085
  let group = this.repoGroups.find((g) => g.key === key);
767
1086
  if (group) return group;
768
- const [host, owner, name] = String(key).split("/");
769
- if (host !== "github.com" || !owner || !name) {
770
- throw new Error(`not cloned on this device — clone it, then start the preview again`);
1087
+ const name = String(key).split("/").pop();
1088
+ const remote = resolveCompanionRemote({ key, remotes, useSsh: prefersSsh(this.repoGroups) });
1089
+ if (!remote || !name) {
1090
+ throw new PreviewError(`Companion ${key} is not cloned on this device and Gleap knows no remote for it — clone it, then start the preview again.`, {
1091
+ code: "companion_missing",
1092
+ repo: key,
1093
+ detail: { remote: remote ?? null },
1094
+ });
771
1095
  }
772
1096
  await report({ status: "starting", error: null, note: `Cloning ${name}…` });
773
- this.log("info", "preview.companion.clone", { key });
774
- await this.cloneRepo({ remote: `https://github.com/${owner}/${name}.git`, name });
1097
+ this.log("info", "preview.companion.clone", { key, remote });
1098
+ let target = null;
1099
+ try {
1100
+ target = await this.cloneRepo({ remote, name, timeoutMs: CLONE_TIMEOUT_MS });
1101
+ } catch (err) {
1102
+ throw new PreviewError(`Companion ${key} could not be cloned: ${err.message}`, {
1103
+ code: "companion_clone_failed",
1104
+ repo: key,
1105
+ detail: { remote, cloneCommand: buildCloneCommand({ remote, target: err.target || target || name }) },
1106
+ });
1107
+ }
775
1108
  group = this.repoGroups.find((g) => g.key === key);
776
- if (!group) throw new Error(`clone finished but the repository was not discovered — check the daemon log`);
1109
+ if (!group) {
1110
+ throw new PreviewError(`Companion ${key}: the clone finished but the repository was not discovered — its remote resolves to a different key. Point Gleap at the checkout (Locate…).`, {
1111
+ code: "companion_missing",
1112
+ repo: key,
1113
+ detail: { remote, cloneCommand: buildCloneCommand({ remote, target }) },
1114
+ });
1115
+ }
777
1116
  return group;
778
1117
  }
779
1118
 
@@ -781,26 +1120,43 @@ export class BridgeDaemon {
781
1120
  * Previews are for looking at, not for hosting: stop everything after
782
1121
  * 30 idle minutes (Lukas 08-26 — was 4h). Re-armed on every (re)start,
783
1122
  * cleared on manual stop and session close. Turns never boot services
784
- * themselves — they only reuse this runner while it is alive.
1123
+ * themselves — they only reuse this runner while it is alive. "Idle"
1124
+ * also means nobody is connected: established connections on a service
1125
+ * port (a browser tab left open, the phone) re-arm the timer.
785
1126
  */
786
1127
  armPreviewIdleTimer(sessionId) {
787
1128
  this.previewIdleTimers ??= new Map();
788
1129
  this.clearPreviewIdleTimer(sessionId);
789
- const t = setTimeout(() => {
1130
+ const t = setTimeout(async () => {
790
1131
  this.previewIdleTimers.delete(sessionId);
791
1132
  const runner = this.services.get(sessionId);
792
1133
  if (!runner) return;
1134
+ if (await this.previewHasConnections(runner)) {
1135
+ this.log("info", "preview.idle.connected", { sessionId });
1136
+ if (this.services.get(sessionId) === runner) this.armPreviewIdleTimer(sessionId);
1137
+ return;
1138
+ }
793
1139
  runner.stopAll();
794
1140
  this.services.delete(sessionId);
795
1141
  this.log("info", "preview.idle.stopped", { sessionId });
796
1142
  this.api
797
- .sessionPreview(sessionId, { status: "stopped", error: "Stopped automatically after 30 minutes." })
1143
+ .sessionPreview(sessionId, { status: "stopped", urls: [], previews: [], errorCode: "idle_stopped", error: "Stopped automatically after 30 minutes." })
798
1144
  .catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
799
1145
  }, this.previewIdleMs ?? 30 * 60 * 1000);
800
1146
  t.unref?.();
801
1147
  this.previewIdleTimers.set(sessionId, t);
802
1148
  }
803
1149
 
1150
+ /** Any ESTABLISHED connection on one of the runner's (non-adopted) ports? Injectable for tests. */
1151
+ async previewHasConnections(runner) {
1152
+ const count = this.countConnections ?? ((port) => establishedConnections(port));
1153
+ for (const [name, port] of Object.entries(runner.ports || {})) {
1154
+ if (runner.adopted?.has(name)) continue;
1155
+ if ((await count(port).catch(() => 0)) > 0) return true;
1156
+ }
1157
+ return false;
1158
+ }
1159
+
804
1160
  clearPreviewIdleTimer(sessionId) {
805
1161
  const t = this.previewIdleTimers?.get(sessionId);
806
1162
  if (t) clearTimeout(t);
@@ -808,17 +1164,63 @@ export class BridgeDaemon {
808
1164
  }
809
1165
 
810
1166
  async previewStop({ sessionId }) {
1167
+ this.manualPreviews?.delete(sessionId);
1168
+ this.verifyOwnedPreviews?.delete(sessionId);
1169
+ // A sign-in window for this session has nothing left to sign in to.
1170
+ await this.teardownLogins((l) => l.sessionId === sessionId, "preview stopped");
811
1171
  this.services.get(sessionId)?.stopAll();
812
1172
  this.services.delete(sessionId);
813
1173
  this.clearPreviewIdleTimer(sessionId);
814
- await this.api.sessionPreview(sessionId, { status: "stopped" }).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
1174
+ await this.api.sessionPreview(sessionId, { status: "stopped", urls: [], previews: [] }).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
1175
+ }
1176
+
1177
+ /**
1178
+ * A service that had become ready died on its own (crash on the first
1179
+ * real request, OOM, the user killed it): the preview is broken NOW. Stop
1180
+ * the rest, drop the runner (Retry reboots cleanly) and tell the
1181
+ * dashboard why — with the classified log line.
1182
+ */
1183
+ onServiceDied(sessionId, info) {
1184
+ const runner = this.services.get(sessionId);
1185
+ if (!runner) return Promise.resolve();
1186
+ this.log("warn", "preview.service.died", { sessionId, name: info.name, code: info.code, errorCode: info.errorCode });
1187
+ runner.stopAll();
1188
+ this.services.delete(sessionId);
1189
+ this.clearPreviewIdleTimer(sessionId);
1190
+ return this.api
1191
+ .sessionPreview(
1192
+ sessionId,
1193
+ previewErrorPayload({
1194
+ code: info.errorCode === "service_crashed" || !info.errorCode ? "service_crashed" : info.errorCode,
1195
+ error: `${info.name} stopped: ${info.error}`,
1196
+ repo: info.repoKey,
1197
+ service: info.name,
1198
+ detail: { ...(info.detail || {}), logPath: info.logPath, exitCode: info.code },
1199
+ }),
1200
+ )
1201
+ .catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
815
1202
  }
816
1203
 
817
1204
  /** One ServiceRunner per session — created only by the on-demand preview; turns reuse it. */
818
- runnerFor(sessionId, onStatus) {
1205
+ runnerFor(sessionId) {
819
1206
  let runner = this.services.get(sessionId);
820
1207
  if (!runner) {
821
- runner = new ServiceRunner({ kaiHome: this.kaiHome, sessionId, log: this.log, onStatus });
1208
+ // Stable ports (hash of repo + service in 43000-43999, when free) keep
1209
+ // a saved sign-in's origins matching from one session to the next.
1210
+ runner = new ServiceRunner({
1211
+ kaiHome: this.kaiHome,
1212
+ sessionId,
1213
+ log: this.log,
1214
+ onStatus: (message) => {
1215
+ this.log("info", "preview.status", { sessionId, message });
1216
+ this.previewStatusListeners?.get(sessionId)?.(message);
1217
+ },
1218
+ preferredPort: ({ repoKey, service }) => preferredStablePort(repoKey, service),
1219
+ ...(this.describeListener ? { describeListener: this.describeListener } : {}),
1220
+ ...(this.previewSettleMs != null ? { settleMs: this.previewSettleMs } : {}),
1221
+ onServiceExit: (info) => this.onServiceDied(sessionId, info),
1222
+ onProcess: (name, pid, op) => this.trackServicePid(sessionId, name, pid, op),
1223
+ });
822
1224
  this.services.set(sessionId, runner);
823
1225
  }
824
1226
  return runner;
@@ -839,6 +1241,7 @@ export class BridgeDaemon {
839
1241
  title: turn.title,
840
1242
  });
841
1243
  bound.push({ key: r.key, ...ws });
1244
+ if (ws.deps) this.log("info", "deps.seed", { repo: r.key, ...ws.deps });
842
1245
  // Remember the choice per repo (the UI asks once, then sticks).
843
1246
  this.config.repoModes = { ...(this.config.repoModes || {}), [r.key]: mode };
844
1247
  }
@@ -897,10 +1300,16 @@ export class BridgeDaemon {
897
1300
  const { turnId } = turn;
898
1301
  if (this.running.has(turnId)) return;
899
1302
  const ctrl = new AbortController();
900
- this.running.set(turnId, ctrl);
1303
+ const entry = { ctrl, control: null, sessionId: turn.sessionId };
1304
+ this.running.set(turnId, entry);
901
1305
  const releaseAwake = keepAwake();
902
1306
  let outcome = null;
903
- this.rememberInflight(turnId);
1307
+ // Verify turns (`agent: kai-verifier`): preview booted up front, browser
1308
+ // evidence collected into an artifacts dir, report uploaded after the
1309
+ // turn — see prepareVerifyTurn / finishVerification.
1310
+ const isVerify = turn.agent === "kai-verifier";
1311
+ let verify = null;
1312
+ this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null });
904
1313
  const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
905
1314
  try {
906
1315
  const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === turn.profileId) ?? { id: "gleap-key", kind: "gleap-key", harness: turn.harness };
@@ -910,70 +1319,143 @@ export class BridgeDaemon {
910
1319
  // local paths — the prompt lists them.
911
1320
  const workDir = bound[0]?.cwd;
912
1321
  if (!workDir) throw new Error("Turn has no repositories.");
913
- const repoNote = this.repoBrief(turn, bound);
914
- // Previews are manual-only (dashboard "Start preview") — a turn
915
- // never boots dev servers on its own. When the user already has a
916
- // preview running for this session, describe it to the agent and
917
- // hand it the Playwright MCP so it can verify in a real browser.
918
- const { note: previewNote, hasLivePreview } = await this.describeLivePreview(turn, bound, batcher);
919
- const mcpServers = hasLivePreview ? [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)] : turn.mcpServers;
1322
+ const repoNote = isVerify ? this.verifyRepoBrief(bound) : this.repoBrief(turn, bound);
1323
+ let previewNote = "";
1324
+ let mcpServers = turn.mcpServers;
1325
+ if (isVerify) {
1326
+ verify = await this.prepareVerifyTurn(turn, bound);
1327
+ if (!verify.ok) {
1328
+ // Nothing to test against (preview dead) or a sign-in wall the
1329
+ // user must clear first: file a blocked report and close the turn
1330
+ // cleanly without spending an agent run.
1331
+ await this.api
1332
+ .turnVerification(turnId, { ...buildVerificationPayload(null, [], { fallbackReason: verify.error }), ...(verify.blocked || {}) })
1333
+ .catch((err) => this.log("warn", "verify.report.failed", { turnId, error: err.message }));
1334
+ outcome = {
1335
+ status: "completed",
1336
+ result: null,
1337
+ changes: bound.map((b) => ({ key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, files: [], push: false })),
1338
+ profileId: profile.id,
1339
+ };
1340
+ return;
1341
+ }
1342
+ previewNote = verify.note;
1343
+ // The evidence dir is where a restart mid-run finds partial footage.
1344
+ this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent, artifactsDir: verify.artifactsDir });
1345
+ mcpServers = [
1346
+ ...(turn.mcpServers || []),
1347
+ previewMcpServer(RUNNER_DIR, {
1348
+ outputDir: verify.artifactsDir,
1349
+ // `storage` = browser_storage_state for the final sign-in refresh;
1350
+ // restoring an arbitrary state file is never the agent's call.
1351
+ caps: ["devtools", "testing", "storage"],
1352
+ secretsFile: verify.secretsFile,
1353
+ storageStateFile: verify.storageStateFile,
1354
+ allowedOrigins: verify.storageStateFile ? verify.allowedOrigins : null,
1355
+ disabledTools: ["browser_set_storage_state"],
1356
+ ignoreHttpsErrors: !!verify.ignoreHttpsErrors,
1357
+ }),
1358
+ ];
1359
+ await this.postVerifyStage(turnId, "verifying");
1360
+ } else {
1361
+ // Previews are manual-only (dashboard "Start preview") — a turn
1362
+ // never boots dev servers on its own. When the user already has a
1363
+ // preview running for this session, describe it to the agent and
1364
+ // hand it the Playwright MCP so it can verify in a real browser.
1365
+ const live = await this.describeLivePreview(turn, bound, batcher);
1366
+ previewNote = live.note;
1367
+ if (live.hasLivePreview) mcpServers = [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)];
1368
+ }
920
1369
  const res = await runTurn({
921
1370
  turn: { ...turn, task: `${turn.task}${repoNote}${previewNote}`, mcpServers },
922
1371
  profile,
923
1372
  workDir,
924
1373
  kaiHome: this.kaiHome,
1374
+ // The verify MCP's request policy (KAI_VERIFY_*) — host-only, never in the prompt.
1375
+ extraEnv: verify?.env ?? null,
925
1376
  signal: ctrl.signal,
926
- onEvent: (ev) => batcher.push(ev),
1377
+ onSpawn: (handle) => {
1378
+ entry.control = handle.control;
1379
+ },
1380
+ onEvent: (ev) => {
1381
+ if (verify) {
1382
+ // The tester is using the preview — it must not idle-stop
1383
+ // under it. And the report carries LOCAL paths: keep it here,
1384
+ // the uploaded version goes out after the turn.
1385
+ this.armPreviewIdleTimer(turn.sessionId);
1386
+ if (ev?.type === "verify_report") {
1387
+ verify.report = ev.report && typeof ev.report === "object" ? ev.report : null;
1388
+ return;
1389
+ }
1390
+ // Injected sign-in values (cookies, tokens) must never reach
1391
+ // the Server in a tool row or snapshot.
1392
+ if (verify.redact?.size) ev = redactDeep(ev, verify.redact);
1393
+ }
1394
+ batcher.push(ev);
1395
+ },
927
1396
  onLog: (l) => this.log("debug", "runner", { line: l.slice(0, 500) }),
928
1397
  });
929
1398
  await batcher.flush();
930
1399
  const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
1400
+ // Plan and verify turns are read-only: worktrees are restored, local
1401
+ // checkouts only reported, nothing is ever committed or pushed.
1402
+ const readOnlyTurn = !!turn.planMode || isVerify;
931
1403
  const changes = [...bound, ...this.adoptSessionWorktrees(turn, bound)].map((b) => {
932
1404
  ensureCommitExcludes(b.cwd, { allowDevConfig: !!turn.allowDevConfig });
933
- // A plan turn must leave the worktree as it found it — see
1405
+ // A read-only turn must leave the worktree as it found it — see
934
1406
  // discardChanges. Local checkouts are the user's; only report.
935
- if (turn.planMode) {
1407
+ if (readOnlyTurn) {
936
1408
  const leaked = b.mode === "worktree" ? discardChanges(b.cwd).discarded : collectChanges(b.cwd).files;
937
1409
  if (leaked.length > 0) {
938
- this.log("warn", "plan.changes", { turnId, repo: b.key, mode: b.mode, files: leaked.length, discarded: b.mode === "worktree" });
1410
+ const mode = turn.planMode ? "Plan mode" : "Verification";
1411
+ this.log("warn", `${turn.planMode ? "plan" : "verify"}.changes`, { turnId, repo: b.key, mode: b.mode, files: leaked.length, discarded: b.mode === "worktree" });
939
1412
  batcher.push({
940
1413
  type: "text",
941
1414
  message:
942
1415
  b.mode === "worktree"
943
- ? `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.`
944
- : `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.`,
1416
+ ? `${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" : ""}.`
1417
+ : `${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" : ""}.`,
945
1418
  });
946
1419
  }
947
1420
  }
948
1421
  const diff = collectChanges(b.cwd);
949
1422
  // Build turns in worktree mode publish the session branch so the
950
- // Server can open the PR; plan turns and local mode never push.
951
- const shouldPush = completed && b.mode === "worktree" && !turn.planMode && diff.files.length > 0;
1423
+ // Server can open the PR; read-only turns and local mode never push.
1424
+ const shouldPush = completed && b.mode === "worktree" && !readOnlyTurn && diff.files.length > 0;
952
1425
  const push = shouldPush
953
1426
  ? commitAndPush(b.cwd, {
954
1427
  branch: b.branch,
955
1428
  allowDevConfig: !!turn.allowDevConfig,
956
1429
  message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}`,
957
1430
  })
958
- : null;
1431
+ : isVerify
1432
+ ? false
1433
+ : null;
959
1434
  // `cwd` travels with the change so the dashboard can point at
960
1435
  // work that stayed on this machine (files but no push).
961
- return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, adopted: b.adopted || undefined, ...diff, push };
1436
+ // Pushed → the commit list + diff stat travel with the change so the
1437
+ // Server can write a real pull request description.
1438
+ const described = push?.pushed ? describeBranchChanges(b.cwd, b.base) : null;
1439
+ return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, adopted: b.adopted || undefined, ...diff, push, ...(described ? described : {}) };
962
1440
  });
963
- // The plan-mode notices above were queued AFTER the post-turn
1441
+ // The read-only notices above were queued AFTER the post-turn
964
1442
  // flush; land them before the result closes the turn (the Server
965
1443
  // answers 410 for events on an ended turn).
966
1444
  await batcher.flush();
1445
+ if (verify) {
1446
+ const finished = await this.finishVerification(turn, { ...verify, workDir, bound }, res, ctrl.signal.aborted);
1447
+ await this.settleVerifyPreview(turn, finished?.status).catch((err) => this.log("warn", "verify.preview.release.failed", { turnId, error: err.message }));
1448
+ }
967
1449
  // Built OUTSIDE the report call: if posting the result throws, the
968
1450
  // catch below must not turn a finished turn into a failed one. The
969
1451
  // work is already committed and pushed at this point.
970
1452
  outcome = {
971
1453
  status: ctrl.signal.aborted ? "cancelled" : res.rateLimited ? "rate_limited" : res.code === 0 ? "completed" : "failed",
972
1454
  exitCode: res.code,
973
- result: res.result,
1455
+ result: verify?.redact?.size ? redactDeep(res.result, verify.redact) : res.result,
974
1456
  // The runner's own failure text (e.g. the engine's usage-limit
975
1457
  // message) — the Server prefers this over its generic fallback.
976
- ...(res.lastError && res.code !== 0 ? { error: res.lastError.replace(/^acp-runner: /, "").slice(0, 500) } : {}),
1458
+ ...(res.lastError && res.code !== 0 ? { error: redactDeep(res.lastError.replace(/^acp-runner: /, "").slice(0, 500), verify?.redact) } : {}),
977
1459
  changes,
978
1460
  profileId: profile.id,
979
1461
  };
@@ -992,11 +1474,865 @@ export class BridgeDaemon {
992
1474
  })
993
1475
  .catch((err) => this.log("error", "result.lost", { turnId, error: err.message }));
994
1476
  }
1477
+ // Test credentials and the injected / final sign-in state live on
1478
+ // disk only for the duration of the turn.
1479
+ for (const f of [verify?.secretsFile, verify?.storageStateFile, verify?.finalStateFile]) if (f) rmSync(f, { force: true });
1480
+ verify?.release?.();
995
1481
  this.forgetInflight(turnId);
996
1482
  releaseAwake();
997
1483
  this.running.delete(turnId);
998
- if (this.updatePending && this.running.size === 0) void this.checkForUpdate();
1484
+ if ((this.updatePending || this.restartPending) && this.running.size === 0 && this.logins.size === 0) void this.checkForUpdate();
1485
+ }
1486
+ }
1487
+
1488
+ // ── verify turns ───────────────────────────────────────────────────
1489
+ /** Overridable seam (tests, embedded hosts): a browser the Playwright MCP can launch. */
1490
+ ensurePreviewBrowser() {
1491
+ return ensurePreviewBrowser({ runnerDir: RUNNER_DIR, log: this.log });
1492
+ }
1493
+
1494
+ /** Seams for the sign-in flows (tests inject a fake Playwright / probe / capture). */
1495
+ loadPlaywright() {
1496
+ return loadPlaywright(RUNNER_DIR);
1497
+ }
1498
+ displayAvailable() {
1499
+ return displayAvailable();
1500
+ }
1501
+ probeLogin(opts) {
1502
+ return probeLogin(opts);
1503
+ }
1504
+ captureLogin(opts) {
1505
+ return captureLogin(opts);
1506
+ }
1507
+
1508
+ /** Where a verify turn's injected sign-in state lives (0600, deleted with the turn). */
1509
+ loginStateDir() {
1510
+ return join(this.kaiHome, "state", "preview-login");
1511
+ }
1512
+
1513
+ /** The agent's final browser-state export — inside the turn's evidence dir (the MCP's only writable root besides the repo). */
1514
+ finalStateFileFor(artifactsDir) {
1515
+ return join(artifactsDir, "state", "final-storage-state.json");
1516
+ }
1517
+
1518
+ /** Best-effort stage transition for the dashboard's Verify card (booting → verifying → saving). */
1519
+ async postVerifyStage(turnId, stage, note) {
1520
+ // The note often relays the agent's last line — markdown emphasis
1521
+ // ("**Confirming…**") must not reach the card as literal asterisks.
1522
+ const plain = typeof note === "string" ? note.replace(/[#*`>_]/g, "").replace(/\s+/g, " ").trim().slice(0, 200) : "";
1523
+ try {
1524
+ await this.api.turnVerificationStage(turnId, { stage, ...(plain ? { note: plain } : {}) });
1525
+ } catch (err) {
1526
+ this.log("debug", "verify.stage.failed", { turnId, stage, error: err?.message });
1527
+ }
1528
+ }
1529
+
1530
+ /**
1531
+ * One probe / refresh of a sign-in at a time per user + repo: two verify
1532
+ * turns racing the same record would both refresh it (one 409s) and
1533
+ * could probe a preview the other is still booting.
1534
+ */
1535
+ withVerifyLock(key, fn) {
1536
+ return this.acquireVerifyLock(key).then(async (release) => {
1537
+ try {
1538
+ return await fn();
1539
+ } finally {
1540
+ release();
1541
+ }
1542
+ });
1543
+ }
1544
+
1545
+ /**
1546
+ * Acquire the per user+repo verify lock; resolves with `release()`. A
1547
+ * turn that injected a saved sign-in keeps it until its final refresh
1548
+ * has landed: two agent runs presenting the same refresh token in
1549
+ * parallel is exactly what rotation-based IdPs revoke on.
1550
+ */
1551
+ acquireVerifyLock(key) {
1552
+ this.verifyLockHolders ??= new Set(); // keys currently HELD (the map also lists waiters)
1553
+ this.verifyLocks ??= new Map();
1554
+ const prev = this.verifyLocks.get(key) ?? Promise.resolve();
1555
+ let release;
1556
+ const held = new Promise((resolve) => {
1557
+ let done = false;
1558
+ release = () => {
1559
+ if (done) return;
1560
+ done = true;
1561
+ this.verifyLockHolders.delete(key);
1562
+ resolve();
1563
+ };
1564
+ });
1565
+ const next = prev.catch(() => {}).then(() => held);
1566
+ this.verifyLocks.set(key, next);
1567
+ next.finally(() => {
1568
+ if (this.verifyLocks.get(key) === next) this.verifyLocks.delete(key);
1569
+ });
1570
+ return prev
1571
+ .catch(() => {})
1572
+ .then(() => {
1573
+ this.verifyLockHolders.add(key);
1574
+ return release;
1575
+ });
1576
+ }
1577
+
1578
+ /** The verifier's repo brief: where the checkouts are, and that they are read-only. */
1579
+ verifyRepoBrief(bound) {
1580
+ 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):"];
1581
+ for (const b of bound) lines.push(`- ${b.key}: ${b.cwd}${b.branch ? ` (branch ${b.branch}${b.base ? `, base ${b.base}` : ""})` : ""}`);
1582
+ return lines.join("\n");
1583
+ }
1584
+
1585
+ /**
1586
+ * dev.yaml `auth.storageState: <command>` — run in the repo checkout,
1587
+ * stdout is Playwright storageState JSON (or the path of a JSON file).
1588
+ * Replaces the user's saved sign-in for this repo. Null on any failure.
1589
+ */
1590
+ async storageStateFromCommand(command, cwd) {
1591
+ return new Promise((resolveP) => {
1592
+ const cb = (err, stdout) => {
1593
+ if (err) {
1594
+ this.log("warn", "verify.login.command.failed", { error: err.message });
1595
+ return resolveP(null);
1596
+ }
1597
+ resolveP(parseStorageStateOutput(stdout, { read: (p) => readFileSync(p, "utf8") }));
1598
+ };
1599
+ const opts = { cwd, timeout: 60_000, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, BROWSER: "none" } };
1600
+ if (process.platform === "darwin") execFile("/bin/zsh", ["-lc", command], opts, cb);
1601
+ else execFile(command, [], { ...opts, shell: true }, cb);
1602
+ });
1603
+ }
1604
+
1605
+ /**
1606
+ * Boot (or re-describe) the session's preview, make sure a browser can
1607
+ * launch, decide the sign-in (dev.yaml command → saved record → none),
1608
+ * PROBE it headless, and lay out the turn's evidence dir + state files +
1609
+ * optional secrets file. Resolves `{ ok: false, error, blocked? }`
1610
+ * (`blocked` = `{ blockedCode, loginPath?, firstTime? }` for the report)
1611
+ * or `{ ok: true, artifactsDir, secretsFile, storageStateFile,
1612
+ * finalStateFile, allowedOrigins, injected, redact, note, report: null }`.
1613
+ */
1614
+ async prepareVerifyTurn(turn, bound = []) {
1615
+ const repoKeys = (turn.repos || []).map((r) => r.key).filter(Boolean).sort();
1616
+ const lockKey = `${turn.userId ?? turn.ownerUserId ?? "device"}:${repoKeys.join(",")}`;
1617
+ if (this.verifyLockHolders?.has(lockKey)) {
1618
+ // Another verify run of this repo holds its sign-in — say so instead
1619
+ // of sitting on "Starting the app…" until it finishes.
1620
+ this.log("info", "verify.lock.wait", { turnId: turn.turnId, lockKey });
1621
+ await this.postVerifyStage(turn.turnId, "booting", "Waiting for another verification of this repo on this machine to finish…");
999
1622
  }
1623
+ const release = await this.acquireVerifyLock(lockKey);
1624
+ let result;
1625
+ try {
1626
+ result = await this.prepareVerifyTurnLocked(turn, bound);
1627
+ } catch (err) {
1628
+ release();
1629
+ throw err;
1630
+ }
1631
+ if (result?.ok && result.injected) {
1632
+ // Hold the repo's sign-in for the whole run — released by startTurn's
1633
+ // finally (after finishVerification refreshed the record).
1634
+ return { ...result, release };
1635
+ }
1636
+ release();
1637
+ return result;
1638
+ }
1639
+
1640
+ async prepareVerifyTurnLocked(turn, bound) {
1641
+ const { turnId, sessionId } = turn;
1642
+ // Lazy browser check — setup usually did this; a machine that skipped
1643
+ // it downloads Chromium now (best-effort: without a browser the agent
1644
+ // reports `blocked` itself).
1645
+ const browser = await this.ensurePreviewBrowser().catch((err) => ({ ok: false, error: err?.message }));
1646
+ if (!browser?.ok) {
1647
+ // No browser, no test — say so with the exact command (Linux/Windows
1648
+ // machines without Chrome and a failed Chromium download) instead of
1649
+ // letting the agent discover it on its first navigate.
1650
+ this.log("warn", "verify.browser.unavailable", { turnId, error: browser?.error });
1651
+ return {
1652
+ ok: false,
1653
+ 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.` : ""}`,
1654
+ blocked: { blockedCode: "no_browser" },
1655
+ };
1656
+ }
1657
+ const channel = browser?.browser === "chrome" ? "chrome" : null;
1658
+ // previewStart is idempotent on a live runner (booted services are
1659
+ // only re-described) and reports starting/running to the dashboard
1660
+ // exactly like the manual button. A cold boot is its own stage, and
1661
+ // every runner status line during it (installing, still starting,
1662
+ // ready) reaches the Verify card as a `booting` note — the heartbeat.
1663
+ const coldBoot = !this.services.has(sessionId);
1664
+ if (coldBoot && !this.manualPreviews?.has(sessionId)) (this.verifyOwnedPreviews ??= new Set()).add(sessionId);
1665
+ if (coldBoot) await this.postVerifyStage(turnId, "booting");
1666
+ const heartbeat = createStageHeartbeat((note) => this.postVerifyStage(turnId, "booting", note), { minIntervalMs: this.stageHeartbeatMs ?? 5_000 });
1667
+ this.previewStatusListeners ??= new Map();
1668
+ this.previewStatusListeners.set(sessionId, heartbeat.note);
1669
+ let preview;
1670
+ try {
1671
+ preview = await this.previewStart({ sessionId, title: turn.title, repos: turn.repos, companionRemotes: turn.companionRemotes ?? null });
1672
+ } finally {
1673
+ this.previewStatusListeners?.delete(sessionId);
1674
+ heartbeat.stop();
1675
+ }
1676
+ if (preview?.status !== "running") {
1677
+ // The preview's structured error IS the diagnosis: its code becomes the
1678
+ // verification's blockedCode (companion_missing, port_busy, deps_failed,
1679
+ // …) so the card offers the right verb; `preview_unreachable` only
1680
+ // when nothing more specific is known.
1681
+ const code = preview?.errorCode && preview.errorCode !== "other" ? preview.errorCode : "preview_unreachable";
1682
+ return {
1683
+ ok: false,
1684
+ error: preview?.error ? `The preview could not be started: ${preview.error}` : "The preview could not be started.",
1685
+ blocked: { blockedCode: code },
1686
+ };
1687
+ }
1688
+ // After a fix turn, services that do not hot-reload (`reload: restart`,
1689
+ // the default for APIs) must run the fixed code before the probe.
1690
+ const restartNotes = await this.restartChangedServices(turn, sessionId);
1691
+ const artifactsDir = join(this.kaiHome, "artifacts", String(turnId).replace(/[^\w.-]/g, "_"));
1692
+ mkdirSync(artifactsDir, { recursive: true });
1693
+ let secretsFile = null;
1694
+ let secretNames = [];
1695
+ if (turn.verifySecrets && typeof turn.verifySecrets === "object") {
1696
+ const dir = join(this.kaiHome, "state", "verify-secrets");
1697
+ mkdirSync(dir, { recursive: true });
1698
+ const path = join(dir, `${String(turnId).replace(/[^\w.-]/g, "_")}.env`);
1699
+ secretNames = writeSecretsFile(path, turn.verifySecrets);
1700
+ if (secretNames.length > 0) secretsFile = path;
1701
+ }
1702
+ const previews = preview.previews || [];
1703
+ const runner = this.services.get(sessionId);
1704
+ const services = runner?.describeServices?.() ?? previews.map((p) => ({ name: p.name, url: p.url }));
1705
+ const previewOrigins = [...new Set([...services.map((s) => normalizeOrigin(s.url)), ...previews.map((p) => normalizeOrigin(p.url))].filter(Boolean))];
1706
+ const primaryKey = (turn.repos || [])[0]?.key;
1707
+ // The landing page is a session repo's web preview (an API has no UI to land on).
1708
+ const webPreviews = previews.filter((p) => p.kind !== "api");
1709
+ 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;
1710
+
1711
+ // ── sign-in: dev.yaml command → saved record → none ──────────────
1712
+ const primary = bound?.[0] ?? null;
1713
+ const devConfig = primary?.cwd ? readDevConfig(primary.cwd) : null;
1714
+ const auth = devConfig?.auth ?? null;
1715
+ // Every dev.yaml in the session (external origins, verify policy) — the
1716
+ // primary's wins on conflicts.
1717
+ const configs = (bound || []).map((b) => (b?.cwd ? readDevConfig(b.cwd) : null)).filter(Boolean);
1718
+ const external = [...new Set(configs.flatMap((c) => c.external || []))];
1719
+ // The state filter accepts the declared `external` origins too: the
1720
+ // browser is allowed to talk to them, so a token the app keeps there is
1721
+ // part of "signed in". Cookies stay localhost-only (filterStorageState).
1722
+ const stateOrigins = [...new Set([...previewOrigins, ...external])];
1723
+ let injected = null; // { source: "command" | "record", state, record?, originMap? }
1724
+ if (auth?.storageState && primary?.cwd) {
1725
+ const state = await this.storageStateFromCommand(auth.storageState, primary.cwd);
1726
+ if (state) injected = { source: "command", state: filterStorageState(state, stateOrigins).state, record: null, originMap: null };
1727
+ else this.log("warn", "verify.login.command.empty", { turnId });
1728
+ } else if (turn.loginRecordAvailable) {
1729
+ const record = await this.api.turnPreviewLogin(turnId).catch((err) => {
1730
+ this.log("warn", "verify.login.record.failed", { turnId, error: err?.message });
1731
+ return null;
1732
+ });
1733
+ if (record?.storageState) {
1734
+ const originMap = buildOriginMap(record.services, services);
1735
+ const state = rewriteStorageState(record.storageState, originMap);
1736
+ this.log("info", "verify.login.record", { turnId, version: record.version, mapped: originMap.size, cookies: state.cookies.length, origins: state.origins.length });
1737
+ injected = { source: "record", state, record, originMap };
1738
+ }
1739
+ }
1740
+
1741
+ // ── probe: does the preview consider us signed in? ───────────────
1742
+ // An API-only preview (no web service) has nothing to render a login
1743
+ // wall on — the agent's http_request answers 401/403 → needs_login.
1744
+ let probe = { result: "unknown", storageState: null, loginPath: null };
1745
+ const hasWebService = services.some((svc) => svc.kind !== "api") || webPreviews.length > 0;
1746
+ if (landingUrl && hasWebService) {
1747
+ const pw = this.loadPlaywright();
1748
+ probe = await this.probeLogin({
1749
+ pw,
1750
+ launchOptions: launchOptionsFor({ headless: true, browser: channel }),
1751
+ storageState: injected?.state ?? null,
1752
+ url: injected?.record?.landingUrl ? rewriteUrl(injected.record.landingUrl, injected.originMap) ?? landingUrl : landingUrl,
1753
+ previewOrigins,
1754
+ loginPaths: injected?.record?.loginPaths ?? [],
1755
+ loginCheck: auth?.loginCheck ?? null,
1756
+ // A dev server that just booted still compiles its first page (vite's
1757
+ // cold transform of a large app takes 30-60 s) — give it room, or the
1758
+ // probe answers `unknown` and a login wall goes unnoticed.
1759
+ timeoutMs: coldBoot ? PROBE_TIMEOUT_COLD_BOOT_MS : undefined,
1760
+ log: this.log,
1761
+ });
1762
+ }
1763
+ // "Continue without signing in" (`previewLoginOptional`) means: run
1764
+ // unauthenticated rather than block — whether there is no record at all
1765
+ // or the saved one no longer works.
1766
+ if (probe.result === "needs_login" && turn.previewLoginOptional === true) {
1767
+ this.log("info", "verify.login.optional", { turnId, hadRecord: injected !== null });
1768
+ injected = null;
1769
+ probe = { result: "unknown", storageState: null, loginPath: probe.loginPath };
1770
+ }
1771
+ if (probe.result === "needs_login") {
1772
+ rmSync(artifactsDir, { recursive: true, force: true });
1773
+ if (secretsFile) rmSync(secretsFile, { force: true });
1774
+ const firstTime = injected === null;
1775
+ return {
1776
+ ok: false,
1777
+ error: firstTime
1778
+ ? "The preview asks for a sign-in. Sign in once on this device so Kai can test the app."
1779
+ : "The saved sign-in for this preview no longer works — sign in again on this device.",
1780
+ blocked: { blockedCode: "needs_login", ...(probe.loginPath ? { loginPath: probe.loginPath } : {}), firstTime },
1781
+ };
1782
+ }
1783
+
1784
+ // ── state files for the MCP ──────────────────────────────────────
1785
+ let storageStateFile = null;
1786
+ let finalStateFile = null;
1787
+ let redact = new Set();
1788
+ let probeExport = null;
1789
+ if (injected && !storageStateIsEmpty(injected.state)) {
1790
+ const dir = this.loginStateDir();
1791
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1792
+ const safe = String(turnId).replace(/[^\w.-]/g, "_");
1793
+ storageStateFile = join(dir, `${safe}.json`);
1794
+ // The persona's final `browser_storage_state` export must land INSIDE
1795
+ // the MCP's `--output-dir`: the Playwright MCP refuses any `filename`
1796
+ // outside its output dir / the client cwd (`File access denied …
1797
+ // outside allowed roots`), so a path under ~/.kai/state would make the
1798
+ // agent's last action fail on every signed-in run and the record would
1799
+ // never refresh. A `.json` is not an artifact extension — the sweep
1800
+ // never uploads it — and it is read before the evidence dir is deleted.
1801
+ finalStateFile = this.finalStateFileFor(artifactsDir);
1802
+ mkdirSync(dirname(finalStateFile), { recursive: true, mode: 0o700 });
1803
+ // The probe context's own export (with IndexedDB) when the probe ran;
1804
+ // the rewritten record otherwise (probe unknown → proceed anyway).
1805
+ probeExport = probe.result === "ok" && probe.storageState ? filterStorageState(probe.storageState, stateOrigins).state : injected.state;
1806
+ writeFileSync(storageStateFile, JSON.stringify(probeExport), { mode: 0o600 });
1807
+ redact = new Set([...redactionSet(injected.state), ...redactionSet(probeExport)]);
1808
+ }
1809
+ // `.env*` values of the repos + service dirs are secrets too: a tool row
1810
+ // that echoes one (a config dump, an error message) must not carry it
1811
+ // to the Server.
1812
+ for (const value of this.envRedactionValues(bound, services)) redact.add(value);
1813
+ const allowedOrigins = [...previewOrigins, ...external, "http://localhost:*", "http://127.0.0.1:*", "https://localhost:*", "https://127.0.0.1:*"];
1814
+ // ── API evidence: the verify MCP's request policy ───────────────
1815
+ const apiServices = services.filter((svc) => svc.kind === "api");
1816
+ const verifyPolicy = configs.find((c) => c.verify)?.verify ?? devConfig?.verify ?? { readOnly: null, endpoints: [], loginVia: null };
1817
+ const readOnly = verifyPolicy.readOnly ?? apiServices.length > 0;
1818
+ const env = {
1819
+ KAI_VERIFY_READ_ONLY: readOnly ? "1" : "0",
1820
+ KAI_VERIFY_ORIGINS: [...new Set([...previewOrigins, ...external])].join(";"),
1821
+ KAI_VERIFY_EVIDENCE_DIR: artifactsDir,
1822
+ };
1823
+ // The app's own API credential (dev.yaml `auth.apiToken`) read from the
1824
+ // injected sign-in → `Authorization: Bearer …` for http_request. The
1825
+ // value goes to the MCP by env only and joins the redaction set.
1826
+ const apiAuth = deriveApiAuthHeader(auth?.apiToken, probeExport ?? injected?.state ?? null, services);
1827
+ if (apiAuth) {
1828
+ env.KAI_VERIFY_AUTH_HEADER = apiAuth.value;
1829
+ if (apiAuth.token.length >= 8) redact.add(apiAuth.token);
1830
+ }
1831
+ return {
1832
+ ok: true,
1833
+ artifactsDir,
1834
+ secretsFile,
1835
+ storageStateFile,
1836
+ finalStateFile,
1837
+ allowedOrigins,
1838
+ previewOrigins,
1839
+ stateOrigins,
1840
+ external,
1841
+ // Companions run the code they have checked out — their HEADs travel
1842
+ // with the report so "verified" can never be claimed for a stale API.
1843
+ companionRevisions: previews
1844
+ .filter((p) => p.role === "companion" && p.repo && p.commit)
1845
+ .map((p) => ({ repo: p.repo, commit: p.commit, role: "companion", ...(p.branch ? { branch: p.branch } : {}), ...(p.dirty > 0 ? { dirty: p.dirty } : {}) })),
1846
+ ignoreHttpsErrors: !!runner?.usesHttps?.(),
1847
+ env,
1848
+ readOnly,
1849
+ injected: injected ? { ...injected, probeExport } : null,
1850
+ redact,
1851
+ note: this.verifyPreviewNote(sessionId, previews, secretNames, artifactsDir, {
1852
+ signedIn: !!storageStateFile,
1853
+ finalStateFile,
1854
+ services,
1855
+ readOnly,
1856
+ endpoints: verifyPolicy.endpoints || [],
1857
+ external,
1858
+ restartNotes,
1859
+ apiAuth: !!apiAuth,
1860
+ }),
1861
+ report: null,
1862
+ };
1863
+ }
1864
+
1865
+ /**
1866
+ * After a fix turn (`turn.changedRepos` = repo keys the fix touched):
1867
+ * restart the changed repos' services whose reload mode is `restart`
1868
+ * (declared, or the default for `kind: api`) on their same ports. Adopted
1869
+ * services are the user's own dev server — never restarted, the task
1870
+ * note says so. Returns the note lines for the agent.
1871
+ */
1872
+ async restartChangedServices(turn, sessionId) {
1873
+ const changed = Array.isArray(turn.changedRepos) ? turn.changedRepos.map((k) => String(k).toLowerCase()) : [];
1874
+ const runner = this.services.get(sessionId);
1875
+ if (!changed.length || !runner) return [];
1876
+ const notes = [];
1877
+ const byRoot = new Map(); // repoRoot → { key, names }
1878
+ for (const svc of runner.describeServices()) {
1879
+ if (!svc.repoKey || !changed.includes(String(svc.repoKey).toLowerCase()) || !svc.repoRoot) continue;
1880
+ const meta = runner.meta?.get(svc.name);
1881
+ if (effectiveReload(meta?.svc, svc.kind) !== "restart") continue;
1882
+ if (svc.adopted) {
1883
+ notes.push(`${svc.name} is your already-running dev server and was not restarted — make sure it serves the fixed code.`);
1884
+ continue;
1885
+ }
1886
+ const entry = byRoot.get(svc.repoRoot) ?? { key: svc.repoKey, names: [] };
1887
+ entry.names.push(svc.name);
1888
+ byRoot.set(svc.repoRoot, entry);
1889
+ }
1890
+ for (const [repoRoot, { key, names }] of byRoot) {
1891
+ await this.postVerifyStage(turn.turnId, "booting", `Restarting ${names.join(", ")} with the fix…`);
1892
+ this.log("info", "verify.restart", { turnId: turn.turnId, repo: key, services: names });
1893
+ try {
1894
+ const restarted = await runner.restart(repoRoot, { only: names });
1895
+ const dead = restarted.find((r) => r.restarted && !r.ready);
1896
+ if (dead) notes.push(`${dead.name} did not come back after the restart${dead.error ? ` — ${dead.error}` : ""} (see ${dead.logPath}).`);
1897
+ else notes.push(`${names.join(", ")} ${names.length === 1 ? "was" : "were"} restarted with the fix.`);
1898
+ } catch (err) {
1899
+ this.log("warn", "verify.restart.failed", { turnId: turn.turnId, repo: key, error: err?.message });
1900
+ notes.push(`${names.join(", ")} could not be restarted with the fix: ${err?.message}`);
1901
+ }
1902
+ }
1903
+ return notes;
1904
+ }
1905
+
1906
+ /** Secrets from `.env*` files at the repo roots + service cwds (values ≥ 12 chars, placeholders excluded). */
1907
+ envRedactionValues(bound, services) {
1908
+ const dirs = new Set([...(bound || []).map((b) => b?.cwd).filter(Boolean), ...(services || []).map((svc) => svc?.cwd).filter(Boolean)]);
1909
+ const texts = [];
1910
+ for (const dir of dirs) {
1911
+ let names = [];
1912
+ try {
1913
+ names = readdirSync(dir).filter((n) => n === ".env" || (n.startsWith(".env.") && !/\.(example|sample|template)$/.test(n)));
1914
+ } catch {
1915
+ continue;
1916
+ }
1917
+ for (const name of names) {
1918
+ try {
1919
+ texts.push(readFileSync(join(dir, name), "utf8"));
1920
+ } catch {
1921
+ /* unreadable */
1922
+ }
1923
+ }
1924
+ }
1925
+ return envRedactionValues(texts);
1926
+ }
1927
+
1928
+ /** 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. */
1929
+ verifyPreviewNote(sessionId, previews, secretNames = [], artifactsDir = null, { signedIn = false, finalStateFile = null, services = null, readOnly = false, endpoints = [], external = [], restartNotes = [], apiAuth = false } = {}) {
1930
+ const runner = this.services.get(sessionId);
1931
+ const apiNames = new Set((services || []).filter((svc) => svc.kind === "api").map((svc) => svc.name));
1932
+ const webPreviews = previews.filter((p) => !apiNames.has(p.name));
1933
+ const lines = webPreviews.map((p) => {
1934
+ const logPath = runner && !p.adopted ? join(runner.logDir, `${p.name}.log`) : null;
1935
+ return `- ${p.repo}${p.name && p.name !== p.repo ? ` (${p.name})` : ""}: ${p.url}${p.adopted ? " (your already-running dev server)" : ""}${logPath ? ` · logs: ${logPath}` : ""}`;
1936
+ });
1937
+ const parts = ["", "", "This is a verification turn: do not modify files, do not commit."];
1938
+ if (lines.length > 0) {
1939
+ parts.push(`Dev services running for this session:\n${lines.join("\n")}`);
1940
+ parts.push(
1941
+ "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. " +
1942
+ "Tail the service logs with the Read/Bash tools if a page fails to load.",
1943
+ );
1944
+ }
1945
+ const apiServices = (services || []).filter((svc) => svc.kind === "api");
1946
+ if (apiServices.length > 0) {
1947
+ const apiLines = apiServices.map((svc) => {
1948
+ const logPath = runner && !svc.adopted ? join(runner.logDir, `${svc.name}.log`) : null;
1949
+ 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}` : ""}`;
1950
+ });
1951
+ parts.push(
1952
+ `API services (no UI):\n${apiLines.join("\n")}\n` +
1953
+ `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. ` +
1954
+ `Resolve real paths from the OpenAPI spec (or the router files) before calling. ` +
1955
+ (readOnly
1956
+ ? "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`. "
1957
+ : "Writes are allowed in this run — prefer creating throwaway records and say what you created in the report. ") +
1958
+ (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. ") +
1959
+ (endpoints.length ? `Endpoints the repo asks you to cover: ${endpoints.join(", ")}. ` : "") +
1960
+ (lines.length === 0 ? "There is no web UI in this run — no recording is needed; the transcript and the report are the evidence." : ""),
1961
+ );
1962
+ }
1963
+ if (lines.length === 0 && apiServices.length === 0) {
1964
+ parts.push(`Dev services running for this session:\n${previews.map((p) => `- ${p.repo}: ${p.url}`).join("\n") || "- (none)"}`);
1965
+ }
1966
+ if (external.length > 0) parts.push(`External origins the app talks to (allowed for the browser and http_request): ${external.join(", ")}.`);
1967
+ if (restartNotes.length > 0) parts.push(restartNotes.join(" "));
1968
+ if (artifactsDir) {
1969
+ // The MCP resolves a relative `filename` against the CLIENT
1970
+ // workspace (the repo worktree), where it would be discarded with
1971
+ // the turn — only absolute paths under the output dir (or no
1972
+ // filename at all) reach the evidence dir.
1973
+ parts.push(
1974
+ `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.`,
1975
+ );
1976
+ }
1977
+ if (secretNames.length > 0) {
1978
+ parts.push(
1979
+ `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.`,
1980
+ );
1981
+ }
1982
+ if (signedIn && finalStateFile) {
1983
+ parts.push(
1984
+ `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. ` +
1985
+ `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.`,
1986
+ );
1987
+ } else {
1988
+ parts.push(
1989
+ "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.",
1990
+ );
1991
+ }
1992
+ 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.");
1993
+ return parts.join("\n\n").replace(/^\n\n\n\n/, "\n\n");
1994
+ }
1995
+
1996
+ /**
1997
+ * After a verify turn: sweep the evidence dir (the agent may have
1998
+ * forgotten the recording), upload every file, then post the report
1999
+ * with URLs in place of paths (redacted: nothing from the injected
2000
+ * sign-in may leak). No report from the agent → `blocked` with the swept
2001
+ * evidence; a report with zero checks → `blocked` / `no_report` (the
2002
+ * payload builder). A turn CANCELLED before any report skips the uploads
2003
+ * altogether (nobody asked for that footage). `evidence: { failed, kept }`
2004
+ * tells the Server how many uploads failed and where the files still are
2005
+ * (`bridge.verify.evidence.retry` re-uploads them). Then, when a saved
2006
+ * sign-in was injected and the agent left its final `browser_storage_state`
2007
+ * export, refresh the record (CAS on the version we read). The dir is
2008
+ * deleted only when everything landed; otherwise it stays for the retry /
2009
+ * `kai-bridge doctor`.
2010
+ */
2011
+ async finishVerification(turn, verify, res, cancelled) {
2012
+ const { turnId } = turn;
2013
+ await this.postVerifyStage(turnId, "saving");
2014
+ const redact = verify.redact?.size ? (v) => redactDeep(v, verify.redact) : (v) => v;
2015
+ const report = verify.report ? redact(verify.report) : null;
2016
+ // Verify turns never push, so the Server cannot derive the tested heads
2017
+ // from `changes[]` — stamp them here (repo key → HEAD of the checkout).
2018
+ // Companions booted alongside carry `role: "companion"` (their HEAD is
2019
+ // the code the API under test actually served).
2020
+ const revisions = [
2021
+ ...(verify.bound || [])
2022
+ .map((b) => ({ repo: b.key, commit: currentHead(b.cwd) }))
2023
+ .filter((r) => r.repo && r.commit),
2024
+ ...(verify.companionRevisions || []),
2025
+ ];
2026
+ const fallbackReason = cancelled
2027
+ ? "The verification was cancelled before Kai filed a report."
2028
+ : res.code !== 0
2029
+ ? `The verification turn failed${res.lastError ? `: ${res.lastError.replace(/^acp-runner: /, "").slice(0, 300)}` : ""}.`
2030
+ : "Kai finished without filing a verification report";
2031
+ let uploads = [];
2032
+ let uploadFailures = 0;
2033
+ if (cancelled && !report) {
2034
+ this.log("info", "verify.cancelled.no_uploads", { turnId });
2035
+ } else {
2036
+ const swept = await this.uploadSweptEvidence(turnId, verify.artifactsDir, { report, redact: verify.redact, workDir: verify.workDir, signedIn: !!verify.storageStateFile });
2037
+ uploads = swept.uploads;
2038
+ uploadFailures = swept.failed;
2039
+ }
2040
+ const evidence = { failed: uploadFailures, ...(uploadFailures > 0 ? { kept: verify.artifactsDir } : {}) };
2041
+ const payload = redact({ ...buildVerificationPayload(report, uploads, { fallbackReason, cancelled, evidence, readOnly: typeof verify.readOnly === "boolean" ? verify.readOnly : null }), revisions });
2042
+ let reported = false;
2043
+ try {
2044
+ await this.api.turnVerification(turnId, payload, {
2045
+ onRetry: (err, attempt, delay) => this.log("warn", "verify.report.retry", { turnId, attempt, nextInMs: delay, error: err.message }),
2046
+ });
2047
+ reported = true;
2048
+ } catch (err) {
2049
+ this.log("error", "verify.report.failed", { turnId, error: err.message });
2050
+ }
2051
+ this.log("info", "verify.reported", { turnId, status: payload.status, blockedCode: payload.blockedCode, checks: payload.checks.length, artifacts: uploads.length, uploadFailures, reported });
2052
+ // The final-state export lives in the evidence dir: refresh the record
2053
+ // from it BEFORE the dir is deleted.
2054
+ await this.refreshPreviewLogin(turn, verify);
2055
+ if ((reported && uploadFailures === 0) || (cancelled && !report)) rmSync(verify.artifactsDir, { recursive: true, force: true });
2056
+ else this.log("warn", "verify.artifacts.kept", { turnId, dir: verify.artifactsDir });
2057
+ return { status: payload.status };
2058
+ }
2059
+
2060
+ /**
2061
+ * A preview that Verify booted for itself is released once the run has
2062
+ * settled — a manual preview keeps running until the user stops it. A
2063
+ * `failed` run with fix attempts left keeps the app up: the fix turn's
2064
+ * auto re-verify is seconds away and the Server restarts changed services.
2065
+ */
2066
+ async settleVerifyPreview(turn, status) {
2067
+ const sessionId = turn?.sessionId;
2068
+ if (!sessionId || !this.verifyOwnedPreviews?.has(sessionId)) return false;
2069
+ if (this.manualPreviews?.has(sessionId)) return false;
2070
+ const loop = turn.loop && typeof turn.loop === "object" ? turn.loop : null;
2071
+ const retryPending = status === "failed" && loop && Number(loop.attempt ?? 0) < Number(loop.max ?? 0);
2072
+ if (retryPending) return false;
2073
+ this.log("info", "verify.preview.released", { sessionId, status });
2074
+ await this.previewStop({ sessionId });
2075
+ return true;
2076
+ }
2077
+
2078
+ /**
2079
+ * Sweep an evidence dir, union it with the report's declared artifacts,
2080
+ * redact the HTTP transcript in place, upload everything. Resolves
2081
+ * `{ uploads: [{ artifact, url }], failed }`. Trace archives are dropped
2082
+ * from signed-in runs (they contain every request with its cookies).
2083
+ */
2084
+ async uploadSweptEvidence(turnId, artifactsDir, { report = null, redact = null, workDir = null, signedIn = false } = {}) {
2085
+ const swept = sweepArtifacts(artifactsDir).filter((p) => !(signedIn && /\.zip$/i.test(p)));
2086
+ const artifacts = unionArtifacts(report, swept, { outputDir: artifactsDir, workDir }).filter((a) => !(signedIn && a.kind === "trace"));
2087
+ const uploads = [];
2088
+ let failed = 0;
2089
+ for (const artifact of artifacts) {
2090
+ if (artifact.kind === "requests") redactJsonlFile(artifact.path, redact);
2091
+ try {
2092
+ const { url } = await this.api.uploadArtifact(turnId, artifact.path, artifact.contentType, {
2093
+ onRetry: (err, attempt, delay) => this.log("warn", "verify.upload.retry", { turnId, file: artifact.path, attempt, nextInMs: delay, error: err.message }),
2094
+ });
2095
+ uploads.push({ artifact, url });
2096
+ } catch (err) {
2097
+ failed += 1;
2098
+ this.log("warn", "verify.upload.failed", { turnId, file: artifact.path, error: err.message });
2099
+ }
2100
+ }
2101
+ return { uploads, failed, swept: swept.length };
2102
+ }
2103
+
2104
+ /**
2105
+ * `bridge.verify.evidence.retry { turnId }`: the evidence dir kept after
2106
+ * failed uploads is swept and uploaded again; the Server unions the
2107
+ * artifacts by URL (`PUT …/verification/artifacts`) and clears
2108
+ * `evidenceMissing`. The dir goes once everything landed.
2109
+ */
2110
+ async retryEvidence({ commandId, turnId }) {
2111
+ const ack = (payload) => (commandId ? this.api.commandAck(commandId, payload).catch((err) => this.log("warn", "verify.evidence.ack.failed", { turnId, error: err?.message })) : Promise.resolve());
2112
+ if (!turnId) return ack({ ok: false, error: "evidence retry without a turnId" });
2113
+ const dir = join(this.kaiHome, "artifacts", String(turnId).replace(/[^\w.-]/g, "_"));
2114
+ if (!existsSync(dir)) {
2115
+ this.log("warn", "verify.evidence.retry.missing", { turnId, dir });
2116
+ return ack({ ok: false, error: "The evidence for this run is no longer on this machine." });
2117
+ }
2118
+ const { uploads, failed, swept } = await this.uploadSweptEvidence(turnId, dir);
2119
+ 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 } : {}) }));
2120
+ const evidence = { failed, ...(failed > 0 ? { kept: dir } : {}) };
2121
+ try {
2122
+ await this.api.putVerificationArtifacts(turnId, { artifacts, evidence });
2123
+ } catch (err) {
2124
+ this.log("error", "verify.evidence.retry.failed", { turnId, error: err?.message });
2125
+ return ack({ ok: false, error: err?.message || String(err), uploaded: artifacts.length, failed });
2126
+ }
2127
+ this.log("info", "verify.evidence.retried", { turnId, swept, uploaded: artifacts.length, failed });
2128
+ if (failed === 0) rmSync(dir, { recursive: true, force: true });
2129
+ return ack({ ok: true, uploaded: artifacts.length, failed });
2130
+ }
2131
+
2132
+ /**
2133
+ * The agent's final `browser_storage_state` export (cookies + localStorage
2134
+ * — what the app may have rotated during the run) merged over the probe
2135
+ * export's IndexedDB, filtered to the preview origins, mapped BACK onto
2136
+ * the record's captured origins, and PUT with the version we read. 409 =
2137
+ * another run refreshed first → ours is discarded. Only for injected
2138
+ * records (a dev.yaml command owns its own state).
2139
+ */
2140
+ async refreshPreviewLogin(turn, verify) {
2141
+ const { turnId } = turn;
2142
+ const record = verify.injected?.record;
2143
+ if (!record?.id || verify.injected.source !== "record" || !verify.finalStateFile) return;
2144
+ let final;
2145
+ try {
2146
+ if (!existsSync(verify.finalStateFile)) {
2147
+ this.log("info", "verify.login.refresh.skipped", { turnId, reason: "no final state" });
2148
+ return;
2149
+ }
2150
+ final = JSON.parse(readFileSync(verify.finalStateFile, "utf8"));
2151
+ } catch (err) {
2152
+ this.log("warn", "verify.login.refresh.unreadable", { turnId, error: err?.message });
2153
+ return;
2154
+ }
2155
+ const merged = mergeFinalState(verify.injected.probeExport, final);
2156
+ const { state: filtered, counts } = filterStorageState(merged, verify.stateOrigins ?? verify.previewOrigins);
2157
+ const back = rewriteStorageState(filtered, invertOriginMap(verify.injected.originMap));
2158
+ if (storageStateIsEmpty(back)) {
2159
+ this.log("info", "verify.login.refresh.skipped", { turnId, reason: "empty state" });
2160
+ return;
2161
+ }
2162
+ try {
2163
+ await this.api.refreshPreviewLogin(record.id, { storageState: back, basedOnVersion: record.version });
2164
+ this.log("info", "verify.login.refreshed", { turnId, recordId: record.id, basedOnVersion: record.version, ...counts });
2165
+ } catch (err) {
2166
+ 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 });
2167
+ }
2168
+ }
2169
+
2170
+ // ── sign-in windows (bridge.verify.login.*) ────────────────────────
2171
+ /**
2172
+ * Open a headed Chrome at the preview for the user to sign in; the
2173
+ * capture uploads the filtered storage state against `requestId`. Acks
2174
+ * `{ ok: true, opened: true }` once the window is up, `{ ok: false,
2175
+ * error }` when this machine cannot (updating, no display, preview dead).
2176
+ * Idempotent per requestId (the Server replays after sleep).
2177
+ */
2178
+ async loginStart({ commandId, requestId, sessionId, repoKey, previewNeeded, title, repos }) {
2179
+ const ack = (payload) => (commandId ? this.api.commandAck(commandId, payload).catch((err) => this.log("warn", "verify.login.ack.failed", { requestId, error: err?.message })) : Promise.resolve());
2180
+ const status = (payload) => this.api.previewLoginStatus(requestId, payload).catch((err) => this.log("warn", "verify.login.status.failed", { requestId, error: err?.message }));
2181
+ if (!requestId) {
2182
+ await ack({ ok: false, error: "Sign-in request without a requestId." });
2183
+ return;
2184
+ }
2185
+ if (this.logins.has(requestId)) {
2186
+ await ack({ ok: true, opened: true, replayed: true });
2187
+ return;
2188
+ }
2189
+ const entry = { requestId, sessionId, repoKey, handle: null, startedAt: Date.now() };
2190
+ this.logins.set(requestId, entry);
2191
+ try {
2192
+ if (this.updating) throw new Error("This machine is updating kai-bridge — try again in a minute.");
2193
+ if (!this.displayAvailable()) throw new Error("This machine has no display to open a sign-in window on.");
2194
+ const pw = this.loadPlaywright();
2195
+ if (!pw?.chromium) throw new Error("Playwright is not installed next to kai-bridge — reinstall @gleapai/kai-bridge.");
2196
+ const browser = await this.ensurePreviewBrowser().catch((err) => ({ ok: false, error: err?.message }));
2197
+ if (!browser?.ok) throw new Error(browser?.error || "No browser is available on this machine.");
2198
+ // Liveness first: booting a cold preview can take minutes, and the
2199
+ // Server fails a request nobody acknowledged as "device unreachable".
2200
+ // `accepted` moves the request off pending without claiming a window.
2201
+ await ack({ ok: true, accepted: true });
2202
+ // The preview must be up for the user to sign in to — boot it (or,
2203
+ // when it already runs, only re-describe it: previewStart is
2204
+ // idempotent on a live runner and we need its URLs either way).
2205
+ // `repos`/`title` may be absent on the command; then the request's
2206
+ // repo is the whole session and the worktree is looked up by id.
2207
+ const sessionRepos = Array.isArray(repos) && repos.length ? repos : [{ key: repoKey }];
2208
+ this.log("info", "verify.login.preview", { requestId, sessionId, previewNeeded: !!previewNeeded, live: this.services.has(sessionId) });
2209
+ const preview = await this.previewStart({ sessionId, title, repos: sessionRepos });
2210
+ if (preview?.status !== "running") throw new Error(preview?.error ? `The preview could not be started: ${preview.error}` : "The preview could not be started.");
2211
+ entry.previews = preview.previews || [];
2212
+ const runner = this.services.get(sessionId);
2213
+ const services = runner?.describeServices?.() ?? (entry.previews || []).map((p) => ({ name: p.name, url: p.url }));
2214
+ const previewOrigins = [...new Set([...services.map((s) => normalizeOrigin(s.url)), ...(entry.previews || []).map((p) => normalizeOrigin(p.url))].filter(Boolean))];
2215
+ // dev.yaml `external` origins may hold part of the app's own sign-in
2216
+ // state — keepable, but never "back on the app" for the detection.
2217
+ const external = [...new Set([...new Set((services || []).map((s) => s.repoRoot).filter(Boolean))].flatMap((root) => readDevConfig(root)?.external || []))];
2218
+ const url = (entry.previews || []).find((p) => p.repo === repoKey)?.url ?? (entry.previews || [])[0]?.url ?? services[0]?.url;
2219
+ if (!url) throw new Error("The preview has no URL to open.");
2220
+ // Keep the preview alive while the window is open (the idle timer
2221
+ // would otherwise stop the app under the user's nose).
2222
+ this.armPreviewIdleTimer(sessionId);
2223
+ const handle = await this.captureLogin({
2224
+ pw,
2225
+ launchOptions: launchOptionsFor({ headless: false, browser: browser.browser === "chrome" ? "chrome" : null }),
2226
+ url,
2227
+ previewOrigins,
2228
+ stateOrigins: [...previewOrigins, ...external],
2229
+ services: services.map((s) => ({ name: s.name, origin: normalizeOrigin(s.url) })),
2230
+ onStatus: (st, extra) => {
2231
+ this.log("info", "verify.login.status", { requestId, status: st, ...(extra?.error ? { error: extra.error } : {}) });
2232
+ return status({ status: st, ...(extra?.error ? { error: String(extra.error) } : {}) });
2233
+ },
2234
+ onSaved: async (payload, counts) => {
2235
+ await this.api.uploadPreviewLogin(requestId, payload, {
2236
+ tries: 3,
2237
+ onRetry: (err, attempt, delay) => this.log("warn", "verify.login.upload.retry", { requestId, attempt, nextInMs: delay, error: err.message }),
2238
+ });
2239
+ this.log("info", "verify.login.saved", { requestId, repoKey, ...counts, loginPaths: payload.loginPaths.length });
2240
+ },
2241
+ log: this.log,
2242
+ });
2243
+ void handle.finished.then((outcome) => {
2244
+ this.logins.delete(requestId);
2245
+ this.log("info", "verify.login.finished", { requestId, status: outcome?.status, error: outcome?.error });
2246
+ this.hello().catch(() => {});
2247
+ if ((this.updatePending || this.restartPending) && this.running.size === 0 && this.logins.size === 0) void this.checkForUpdate();
2248
+ });
2249
+ if (entry.cancelled) {
2250
+ // Cancelled while Chrome was still launching (the Server has already
2251
+ // settled the request): close the window it just opened, no ack of
2252
+ // `opened`, no status (the cancel already answered).
2253
+ this.log("info", "verify.login.cancelled.launching", { requestId });
2254
+ await handle.cancel();
2255
+ await ack({ ok: false, error: "The sign-in was cancelled before the window opened." });
2256
+ return;
2257
+ }
2258
+ entry.handle = handle;
2259
+ await ack({ ok: true, opened: true });
2260
+ await status({ status: "opened" });
2261
+ // Re-announce right away: `activeLogins` is how the dashboard knows a window is open.
2262
+ await this.hello().catch((err) => this.log("warn", "verify.login.hello.failed", { requestId, error: err?.message }));
2263
+ } catch (err) {
2264
+ this.logins.delete(requestId);
2265
+ this.log("error", "verify.login.start.failed", { requestId, error: err?.message });
2266
+ await ack({ ok: false, error: err?.message || String(err) });
2267
+ await status({ status: "failed", error: err?.message || String(err) });
2268
+ }
2269
+ }
2270
+
2271
+ /** "Mark done": export whatever the window holds now (the heuristic may have missed the sign-in). */
2272
+ async loginDone({ commandId, requestId }) {
2273
+ try {
2274
+ const entry = this.logins.get(requestId);
2275
+ if (!entry?.handle) throw new Error("No sign-in window is open for this request on this machine.");
2276
+ await entry.handle.done();
2277
+ if (commandId) await this.api.commandAck(commandId, { ok: true });
2278
+ } catch (err) {
2279
+ this.log("warn", "verify.login.done.failed", { requestId, error: err?.message });
2280
+ if (commandId) await this.api.commandAck(commandId, { ok: false, error: err?.message || String(err) }).catch(() => {});
2281
+ }
2282
+ }
2283
+
2284
+ async loginCancel({ commandId, requestId }) {
2285
+ try {
2286
+ const entry = this.logins.get(requestId);
2287
+ if (!entry) throw new Error("No sign-in window is open for this request on this machine.");
2288
+ if (entry.handle) await entry.handle.cancel();
2289
+ // Still launching: loginStart closes the window as soon as it is up.
2290
+ else entry.cancelled = true;
2291
+ if (commandId) await this.api.commandAck(commandId, { ok: true });
2292
+ } catch (err) {
2293
+ this.log("warn", "verify.login.cancel.failed", { requestId, error: err?.message });
2294
+ if (commandId) await this.api.commandAck(commandId, { ok: false, error: err?.message || String(err) }).catch(() => {});
2295
+ }
2296
+ }
2297
+
2298
+ /** Close every sign-in window matching `filter` (session close, shutdown). */
2299
+ async teardownLogins(filter, reason) {
2300
+ if (!this.logins?.size) return;
2301
+ const victims = [...this.logins.values()].filter((l) => {
2302
+ try {
2303
+ return filter(l);
2304
+ } catch {
2305
+ return false;
2306
+ }
2307
+ });
2308
+ for (const l of victims) {
2309
+ this.log("info", "verify.login.teardown", { requestId: l.requestId, reason });
2310
+ this.logins.delete(l.requestId);
2311
+ l.cancelled = true; // still launching → loginStart closes it on arrival
2312
+ await l.handle?.cancel?.().catch(() => {});
2313
+ }
2314
+ }
2315
+
2316
+ /**
2317
+ * Mid-turn steering: forward the message to the running runner's stdin
2318
+ * control channel. The runner answers with a `steer` turn event
2319
+ * (outcome injected / promptRequired / …) that rides the normal event
2320
+ * batcher. When the turn is not running here (already ended, never
2321
+ * started, laptop was asleep) we answer `promptRequired` ourselves so
2322
+ * the Server queues the message for the next turn instead of waiting
2323
+ * for its timeout.
2324
+ */
2325
+ async steerTurn({ turnId, steer }) {
2326
+ const id = String(steer?.id || "");
2327
+ const text = typeof steer?.text === "string" ? steer.text : "";
2328
+ if (!turnId || !id || !text.trim()) return;
2329
+ const entry = this.running.get(turnId);
2330
+ const delivered = entry?.control ? entry.control({ type: "steer", id, text }) : false;
2331
+ this.log("info", delivered ? "turn.steer" : "turn.steer.miss", { turnId, id, running: !!entry });
2332
+ if (delivered) return;
2333
+ await this.api
2334
+ .turnEvents(turnId, [{ type: "steer", id, outcome: "promptRequired" }])
2335
+ .catch((err) => this.log("warn", "turn.steer.report.failed", { turnId, id, error: err?.message }));
1000
2336
  }
1001
2337
 
1002
2338
  /**
@@ -1052,21 +2388,70 @@ export class BridgeDaemon {
1052
2388
  return { note: notes.join(""), hasLivePreview: previews.length > 0 };
1053
2389
  }
1054
2390
 
1055
- async cloneRepo({ commandId, remote, name }) {
2391
+ /**
2392
+ * Clone `remote` into the preferred root as `name`. Hardened for
2393
+ * unattended runs: no credential prompts (`GIT_TERMINAL_PROMPT=0`,
2394
+ * `GIT_ASKPASS=echo`), stderr captured for the error, bounded by
2395
+ * `timeoutMs` (default 3 min). Resolves the checkout path; throws an Error
2396
+ * carrying `.target` and the stderr tail. As a command handler
2397
+ * (`bridge.repo.clone`) it acks ok/error instead of throwing.
2398
+ */
2399
+ async cloneRepo({ commandId, remote, name, timeoutMs = CLONE_TIMEOUT_MS }) {
1056
2400
  // Prefer the directory the machine's repos already live in (e.g.
1057
2401
  // ~/Documents/Gleap), not the bare scan root that discovered them
1058
2402
  // (~/Documents) — "Clone here" should land next to the other checkouts.
1059
2403
  const root = preferredCloneRoot(this.repoGroups, (this.config.roots || [])[0] ?? defaultRoots()[0]);
1060
- if (!root) throw new Error("No scan root to clone into — add one with `kai-bridge repo roots add <dir>`.");
1061
- const target = join(root, name);
1062
- await new Promise((resolve, reject) => {
1063
- const p = spawn("git", ["clone", remote, target], { stdio: "ignore" });
1064
- p.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`git clone exited ${code}`))));
1065
- p.on("error", reject);
1066
- });
2404
+ const fail = async (err) => {
2405
+ if (commandId) {
2406
+ await this.api.commandAck(commandId, { ok: false, error: err.message, ...(err.cloneCommand ? { cloneCommand: err.cloneCommand } : {}) }).catch(() => {});
2407
+ return null;
2408
+ }
2409
+ throw err;
2410
+ };
2411
+ if (!root) return fail(new Error("No scan root to clone into — add one with `kai-bridge repo roots add <dir>`."));
2412
+ const target = join(root, String(name || "").replace(/[^\w.-]/g, "-") || "repo");
2413
+ if (existsSync(target)) {
2414
+ const err = new Error(`${target} already exists — remove it or point Gleap at it (Locate…).`);
2415
+ err.target = target;
2416
+ return fail(err);
2417
+ }
2418
+ try {
2419
+ await new Promise((resolve, reject) => {
2420
+ const p = spawn("git", ["clone", remote, target], {
2421
+ stdio: ["ignore", "ignore", "pipe"],
2422
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "echo", SSH_ASKPASS: "echo", GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || "ssh -o BatchMode=yes" },
2423
+ });
2424
+ let stderr = "";
2425
+ p.stderr.on("data", (d) => {
2426
+ stderr = `${stderr}${d}`.slice(-4000);
2427
+ });
2428
+ const timer = setTimeout(() => {
2429
+ try {
2430
+ p.kill("SIGTERM");
2431
+ } catch {}
2432
+ reject(Object.assign(new Error(`git clone timed out after ${Math.round(timeoutMs / 60_000)} minutes`), { target, stderr }));
2433
+ }, timeoutMs);
2434
+ p.on("close", (code) => {
2435
+ clearTimeout(timer);
2436
+ if (code === 0) return resolve();
2437
+ const tail = stderr.trim().split("\n").filter(Boolean).slice(-2).join(" · ").slice(0, 300);
2438
+ reject(Object.assign(new Error(`git clone exited ${code}${tail ? ` — ${tail}` : ""}`), { target, stderr, code }));
2439
+ });
2440
+ p.on("error", (err) => {
2441
+ clearTimeout(timer);
2442
+ reject(Object.assign(err, { target }));
2443
+ });
2444
+ });
2445
+ } catch (err) {
2446
+ this.log("error", "repo.clone.failed", { remote, target, error: err.message });
2447
+ rmSync(target, { recursive: true, force: true });
2448
+ err.cloneCommand = buildCloneCommand({ remote, target });
2449
+ return fail(err);
2450
+ }
1067
2451
  await this.scanRepos();
1068
- await this.hello();
2452
+ await this.hello().catch(() => {});
1069
2453
  if (commandId) await this.api.commandAck(commandId, { ok: true, path: target });
2454
+ return target;
1070
2455
  }
1071
2456
 
1072
2457
  /**