@gleapai/kai-bridge 0.9.1 → 0.10.1

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/preview.mjs CHANGED
@@ -9,10 +9,7 @@ import { previewPorts } from './preview-ports.mjs';
9
9
  // web: { cwd: Frontend, run: "npm start", port: 3000, env: { API_URL: "http://localhost:9000" }, protocol: http }
10
10
  // preview: web
11
11
  // companions: [github.com/owner/api]
12
- // verify: { readOnly: true, endpoints: [/api/v1/tickets], loginVia: github.com/owner/web }
13
12
  // external: [https://cdn.example.com]
14
- // auth: { storageState: <command>, loginCheck: { path, selector },
15
- // apiToken: { from: localStorage, origin: web, key: token, header: Authorization, prefix: "Bearer " } }
16
13
  //
17
14
  // Worktree mode: every service gets a FREE port (the user's own dev server
18
15
  // may already own the declared one) — `PORT` is set and `${port:<name>}`
@@ -36,6 +33,8 @@ import { previewPorts } from './preview-ports.mjs';
36
33
  import { spawn } from "node:child_process";
37
34
  import { createHash } from "node:crypto";
38
35
  import { createServer, connect } from "node:net";
36
+
37
+ import { Gateway } from "./gateway.mjs";
39
38
  import { request as httpsRequest } from "node:https";
40
39
  import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
41
40
  import { createRequire } from "node:module";
@@ -62,11 +61,36 @@ export const PROGRESS_TICK_MS = 30_000;
62
61
  /** Post-ready settle before the re-probe (env override for tests: KAI_PREVIEW_SETTLE_MS). Read at construction time. */
63
62
  const DEFAULT_SETTLE_MS = Symbol("default");
64
63
  const defaultSettleMs = () => Math.max(0, Number(process.env.KAI_PREVIEW_SETTLE_MS ?? 3_000) || 0);
65
- const KNOWN_TOP_KEYS = new Set(["services", "preview", "companions", "auth", "verify", "external"]);
64
+ const KNOWN_TOP_KEYS = new Set(["services", "preview", "companions", "external", "reverseCompanions"]);
66
65
  const KNOWN_SERVICE_KEYS = new Set(["name", "cwd", "run", "port", "health", "env", "readyTimeoutMs", "kind", "openapi", "protocol", "reload", "requires"]);
67
- const KNOWN_AUTH_KEYS = new Set(["storageState", "loginCheck", "apiToken"]);
66
+ /** Stable preview ports: hash(repoKey + service) into this window. */
67
+ export const STABLE_PORT_MIN = 43000;
68
+ export const STABLE_PORT_SPAN = 1000;
69
+
70
+ const EMPTY_CONFIG = () => ({ services: {}, preview: null, companions: [], reverseCompanions: true, external: [], unknownKeys: [], error: null });
71
+
72
+ /** Deterministic port in [43000, 43999] for a repo's service — the same across sessions. */
73
+ export function preferredStablePort(repoKey, serviceName) {
74
+ const h = createHash("sha1").update(`${String(repoKey || "").toLowerCase()} ${String(serviceName || "")}`).digest();
75
+ return STABLE_PORT_MIN + (h.readUInt32BE(0) % STABLE_PORT_SPAN);
76
+ }
77
+
78
+ /** The `playwright` the bundled MCP ships — `null` when it is not installed. */
79
+ export function loadPlaywright(runnerDir) {
80
+ try {
81
+ const req = createRequire(join(runnerDir, "..", "node_modules", "@playwright", "mcp", "package.json"));
82
+ return req("playwright");
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
68
87
 
69
- const EMPTY_CONFIG = () => ({ services: {}, preview: null, companions: [], auth: { storageState: null, loginCheck: null, apiToken: null }, verify: { readOnly: null, endpoints: [], loginVia: null }, external: [], unknownKeys: [], error: null });
88
+ /** Launch options for the warm-up browser: `chrome` when the user's Chrome exists (no download), else the bundled build. */
89
+ export function launchOptionsFor({ headless = true, browser = resolvePreviewBrowser() } = {}) {
90
+ const opts = { headless: !!headless };
91
+ if (browser) opts.channel = browser;
92
+ return opts;
93
+ }
70
94
 
71
95
  /**
72
96
  * Wrap a service/install command so it runs with the daemon's own node
@@ -152,37 +176,6 @@ export function normalizeDevConfig(raw) {
152
176
  };
153
177
  }
154
178
  const preview = typeof raw?.preview === "string" && services[raw.preview] ? raw.preview : Object.keys(services)[0] || null;
155
- // Optional sign-in hooks for Verify (0.4.0):
156
- // auth:
157
- // storageState: "node scripts/test-login.mjs" # prints Playwright storageState JSON (or a path to it)
158
- // loginCheck: { path: /account, selector: "[data-testid=user-menu]" }
159
- // apiToken: { from: localStorage, origin: web, key: token, header: Authorization, prefix: "Bearer " }
160
- // `storageState` replaces the user's saved sign-in record; `loginCheck`
161
- // replaces the probe's login-wall heuristic (selector visible = signed in);
162
- // `apiToken` says where the app keeps its API credential so the daemon can
163
- // hand `http_request` an auth header derived from the injected sign-in.
164
- const rawAuth = raw?.auth && typeof raw.auth === "object" ? raw.auth : {};
165
- for (const k of Object.keys(rawAuth)) if (!KNOWN_AUTH_KEYS.has(k)) unknownKeys.push(`auth.${k}`);
166
- const loginCheck =
167
- rawAuth.loginCheck && typeof rawAuth.loginCheck === "object" && typeof rawAuth.loginCheck.selector === "string" && rawAuth.loginCheck.selector.trim()
168
- ? { path: typeof rawAuth.loginCheck.path === "string" && rawAuth.loginCheck.path.trim() ? rawAuth.loginCheck.path.trim() : "/", selector: rawAuth.loginCheck.selector.trim() }
169
- : null;
170
- const rawToken = rawAuth.apiToken && typeof rawAuth.apiToken === "object" ? rawAuth.apiToken : null;
171
- const apiToken =
172
- rawToken && typeof rawToken.key === "string" && rawToken.key.trim()
173
- ? {
174
- from: rawToken.from === "cookie" ? "cookie" : "localStorage",
175
- origin: typeof rawToken.origin === "string" && rawToken.origin.trim() ? rawToken.origin.trim() : preview,
176
- key: rawToken.key.trim(),
177
- header: typeof rawToken.header === "string" && /^[A-Za-z0-9-]+$/.test(rawToken.header.trim()) ? rawToken.header.trim() : "Authorization",
178
- prefix: typeof rawToken.prefix === "string" ? rawToken.prefix : "Bearer ",
179
- }
180
- : null;
181
- const auth = {
182
- storageState: typeof rawAuth.storageState === "string" && rawAuth.storageState.trim() ? rawAuth.storageState.trim() : null,
183
- loginCheck,
184
- apiToken,
185
- };
186
179
  // Companion repos this preview needs running alongside (an API for a
187
180
  // frontend, etc). Entries are repo keys as reported by the device scan
188
181
  // ("github.com/owner/name") — strings, or {repo, optional} objects.
@@ -193,15 +186,9 @@ export function normalizeDevConfig(raw) {
193
186
  if (!repo) continue;
194
187
  companions.push({ repo: repo.toLowerCase().trim(), optional: !!(entry && typeof entry === "object" && entry.optional) });
195
188
  }
196
- // Verify policy: `readOnly` null = default (true when the run exercises an
197
- // API service — a shared database must not be written by a tester).
198
- const rawVerify = raw?.verify && typeof raw.verify === "object" ? raw.verify : {};
199
- const verify = {
200
- readOnly: typeof rawVerify.readOnly === "boolean" ? rawVerify.readOnly : null,
201
- endpoints: (Array.isArray(rawVerify.endpoints) ? rawVerify.endpoints : []).map((e) => String(e).trim()).filter(Boolean).slice(0, 50),
202
- loginVia: typeof rawVerify.loginVia === "string" && rawVerify.loginVia.trim() ? rawVerify.loginVia.trim().toLowerCase() : null,
203
- };
204
- return { services, preview, companions, auth, verify, external: normalizeOriginList(raw?.external), unknownKeys, error: null };
189
+ // `reverseCompanions: false` keeps this repo out of previews of the repos it
190
+ // names as companions (a frontend normally boots alongside its API's ticket).
191
+ return { services, preview, companions, reverseCompanions: raw?.reverseCompanions !== false, external: normalizeOriginList(raw?.external), unknownKeys, error: null };
205
192
  }
206
193
 
207
194
  /** The reload mode a service actually uses (`hot` unless declared / api). */
@@ -304,14 +291,25 @@ export function lanAddress() {
304
291
  return null;
305
292
  }
306
293
 
307
- /** `${port:api}` → assigned port of service `api`. */
294
+ /**
295
+ * `${port:api}` → assigned port of service `api`, `${url:api}` → its URL.
296
+ * Compatibility for configs written before public links: a browser-facing
297
+ * `http://localhost:${port:api}` becomes the public URL when `api` has one
298
+ * (`127.0.0.1:${port:…}` is the server-to-server form and stays local).
299
+ */
308
300
  export function substitutePorts(value, ports, urls = {}) {
309
- return String(value).replace(/\$\{port:([\w-]+)\}/g, (_, name) => String(ports[name] ?? "")).replace(/\$\{url:([\w-]+)\}/g, (_, name) => {
310
- if (!urls[name]) throw new Error(`Service URL is not available for ${name}.`);
311
- return urls[name];
312
- });
301
+ return String(value)
302
+ .replace(/https?:\/\/localhost:\$\{port:([\w-]+)\}/g, (match, name) => (/^https:\/\//.test(urls[name] || "") ? urls[name] : match))
303
+ .replace(/\$\{port:([\w-]+)\}/g, (_, name) => String(ports[name] ?? ""))
304
+ .replace(/\$\{url:([\w-]+)\}/g, (_, name) => {
305
+ if (!urls[name]) throw new Error(`Service URL is not available for ${name}.`);
306
+ return urls[name];
307
+ });
313
308
  }
314
309
 
310
+ /** Does a service bake another service's address into its env or command (and so must restart when addresses change)? */
311
+ export const bakesServiceUrl = (svc) => /\$\{url:|https?:\/\/localhost:\$\{port:/.test(`${svc?.run ?? ""} ${Object.values(svc?.env || {}).join(" ")}`);
312
+
315
313
  /** GET `url` accepting self-signed certificates (dev https); resolves `{ status, contentType }` or null. */
316
314
  export function fetchInsecure(url, { timeoutMs = 2_000 } = {}) {
317
315
  return new Promise((resolveP) => {
@@ -351,11 +349,13 @@ export function kindFromContentType(contentType) {
351
349
  return ct.includes("text/html") ? "web" : "api";
352
350
  }
353
351
 
354
- async function waitForReady({ port, health, timeoutMs, protocol = "http", onTick = () => {}, tickMs = PROGRESS_TICK_MS }) {
352
+ async function waitForReady({ port, health, timeoutMs, protocol = "http", onTick = () => {}, tickMs = PROGRESS_TICK_MS, isCancelled = () => false }) {
355
353
  const deadline = Date.now() + timeoutMs;
356
354
  const startedAt = Date.now();
357
355
  let nextTick = startedAt + tickMs;
358
356
  while (Date.now() < deadline) {
357
+ // The process is gone — stop polling (and ticking "Still starting…") for the full timeout.
358
+ if (isCancelled()) return false;
359
359
  if (health) {
360
360
  // `localhost`, not 127.0.0.1 — the server may be IPv6-only (see
361
361
  // isPortListening); Node's fetch tries both families.
@@ -405,6 +405,24 @@ function spawnShell(cmd, { cwd, env, fd, detached = false }) {
405
405
  * Runs the services of one repo binding for the lifetime of a turn (or
406
406
  * longer — the daemon keeps them per session until the session ends).
407
407
  */
408
+ /** End a dev server's whole process group (Windows: its tree); never throws. */
409
+ export function killTree(pid, signal = "SIGTERM") {
410
+ if (!Number.isInteger(pid)) return;
411
+ if (process.platform === "win32") {
412
+ spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => {});
413
+ return;
414
+ }
415
+ try {
416
+ process.kill(-pid, signal);
417
+ } catch {
418
+ try {
419
+ process.kill(pid, signal);
420
+ } catch {
421
+ /* gone */
422
+ }
423
+ }
424
+ }
425
+
408
426
  export class ServiceRunner {
409
427
  /**
410
428
  * `preferredPort({ repoKey, service })` → a port to TRY before falling
@@ -416,8 +434,14 @@ export class ServiceRunner {
416
434
  * fires when a service that had become ready dies on its own;
417
435
  * `onProcess(name, pid, "add" | "remove", { port })` lets the daemon persist pids.
418
436
  */
419
- constructor({ kaiHome, sessionId, log = () => {}, onStatus = () => {}, preferredPort = null, describeListener = null, settleMs = DEFAULT_SETTLE_MS, onServiceExit = () => {}, onProcess = () => {}, home = homedir() } = {}) {
437
+ constructor({ kaiHome, sessionId, log = () => {}, onStatus = () => {}, preferredPort = null, describeListener = null, settleMs = DEFAULT_SETTLE_MS, onServiceExit = () => {}, onProcess = () => {}, home = homedir(), gateway = true } = {}) {
420
438
  if (settleMs === DEFAULT_SETTLE_MS) settleMs = defaultSettleMs();
439
+ // Sign-in gateway (gateway.mjs) in front of every bridge-started http
440
+ // service: the public port is the gateway's, the app runs on `appPort`.
441
+ this.gatewayEnabled = gateway !== false;
442
+ this.gateways = new Map(); // service name → Gateway
443
+ this.resumedPids = new Map(); // service name → pid of a dev server re-adopted from a previous daemon life
444
+ this.warmed = false;
421
445
  this.kaiHome = kaiHome;
422
446
  this.sessionId = sessionId;
423
447
  this.serviceUrls = {};
@@ -448,7 +472,7 @@ export class ServiceRunner {
448
472
  * Idempotent per repo root. Throws `PreviewError` (`port_busy`) when a
449
473
  * declared port is held by a process that is not this repo's.
450
474
  */
451
- async assignPorts(repoRoot, config, { mode = "worktree", repoKey = null, envSource = null } = {}) {
475
+ async assignPorts(repoRoot, config, { mode = "worktree", repoKey = null, envSource = null, publicHosts = null } = {}) {
452
476
  if (this.registered.has(repoRoot)) return this.registered.get(repoRoot);
453
477
  // Same service name from a DIFFERENT repo is a collision, not a
454
478
  // reuse: silently sharing the port map entry made the second repo
@@ -477,6 +501,7 @@ export class ServiceRunner {
477
501
  const declared = svc.port;
478
502
  const owner = `${this.sessionId}/${repoRoot}/${svc.name}`;
479
503
  const pinned = !!declared && (mode === 'local' || new RegExp(`(^|[^0-9])${declared}([^0-9]|$)`).test(`${svc.run} ${Object.values(svc.env).join(' ')}`));
504
+ this.meta.get(svc.name).pinned = pinned;
480
505
  if (declared && !(await isPortListening(declared))) {
481
506
  this.ports[svc.name] = await previewPorts.reserve(owner, declared, pinned, isPortListening);
482
507
  continue;
@@ -509,8 +534,13 @@ export class ServiceRunner {
509
534
  const preferred = (declared ? await findFreePortNear(declared) : await this.stablePort(repoKey, svc.name)) ?? (await getFreePort());
510
535
  this.ports[svc.name] = await previewPorts.reserve(owner, preferred, false, isPortListening);
511
536
  }
537
+ // Public mode: `${url:x}` resolves to the service's public address (the
538
+ // browser bundle bakes it in), `${port:x}` stays the local port.
539
+ this.publicHosts ??= {};
512
540
  for (const svc of services) {
513
- this.serviceUrls[svc.name] = `${svc.protocol || 'http'}://localhost:${this.ports[svc.name]}`;
541
+ const hostname = publicHosts?.[svc.name] ?? null;
542
+ if (hostname) this.publicHosts[svc.name] = hostname;
543
+ this.serviceUrls[svc.name] = hostname ? `https://${hostname}` : `${svc.protocol || 'http'}://localhost:${this.ports[svc.name]}`;
514
544
  }
515
545
  this.registered.set(repoRoot, services);
516
546
  this.previewNames ??= new Map();
@@ -536,7 +566,7 @@ export class ServiceRunner {
536
566
  return {
537
567
  services: out,
538
568
  preview: previewSvc
539
- ? { name: previewSvc.name, url: previewSvc.url, lanUrl: lan ? `${previewSvc.protocol}://${lan}:${previewSvc.port}` : null, adopted: !!previewSvc.adopted, kind: previewSvc.kind, protocol: previewSvc.protocol, ...(previewSvc.adopted && previewSvc.cwd ? { adoptedCwd: previewSvc.cwd } : {}) }
569
+ ? { name: previewSvc.name, url: previewSvc.url, lanUrl: lan ? `${previewSvc.protocol}://${lan}:${previewSvc.port}` : null, ...(previewSvc.publicUrl ? { publicUrl: previewSvc.publicUrl } : {}), adopted: !!previewSvc.adopted, kind: previewSvc.kind, protocol: previewSvc.protocol, ...(previewSvc.adopted && previewSvc.cwd ? { adoptedCwd: previewSvc.cwd } : {}) }
540
570
  : null,
541
571
  };
542
572
  }
@@ -546,7 +576,25 @@ export class ServiceRunner {
546
576
  const port = this.ports[name];
547
577
  const url = `${m?.protocol ?? "http"}://localhost:${port}`;
548
578
  const adopted = this.adopted.has(name);
549
- return { name, port, url, logPath: adopted ? null : m?.logPath ?? null, adopted, ready: !!m?.ready, kind: m?.kind ?? "web", protocol: m?.protocol ?? "http", repoKey: m?.repoKey ?? null, repoRoot: m?.repoRoot ?? null, cwd: (adopted ? this.adoptedCwd.get(name) : null) ?? m?.cwd ?? null };
579
+ const hostname = this.publicHosts?.[name] ?? null;
580
+ return { name, port, url, ...(hostname ? { publicUrl: `https://${hostname}` } : {}), logPath: adopted ? null : m?.logPath ?? null, adopted, ready: !!m?.ready, kind: m?.kind ?? "web", protocol: m?.protocol ?? "http", repoKey: m?.repoKey ?? null, repoRoot: m?.repoRoot ?? null, cwd: (adopted ? this.adoptedCwd.get(name) : null) ?? m?.cwd ?? null, fronted: this.gateways.has(name), appPort: m?.appPort ?? null };
581
+ }
582
+
583
+ /** Can a gateway front this service? Not adopted, not pinned to its port, plain http, and switched on. */
584
+ canFront(name) {
585
+ const m = this.meta.get(name);
586
+ return !!m && this.gatewayEnabled && !this.adopted.has(name) && !m.pinned && m.mode !== "local" && (m.protocol ?? "http") === "http";
587
+ }
588
+
589
+ gatewayFor(name) {
590
+ return this.gateways.get(name) ?? null;
591
+ }
592
+
593
+ async closeGateway(name) {
594
+ const gw = this.gateways.get(name);
595
+ if (!gw) return;
596
+ this.gateways.delete(name);
597
+ await gw.close().catch(() => {});
550
598
  }
551
599
 
552
600
  /** Boot one registered service (adopted / already running → described only). */
@@ -561,7 +609,7 @@ export class ServiceRunner {
561
609
  this.onStatus(`Using your running ${name} on :${port}`);
562
610
  return this.describeService(name);
563
611
  }
564
- if (this.processes.has(name)) return this.describeService(name);
612
+ if (this.processes.has(name) || this.resumedPids.has(name)) return this.describeService(name);
565
613
  if (!existsSync(cwd)) {
566
614
  throw new PreviewError(`${name}: the service directory ${svc.cwd} does not exist in ${repoRoot}.`, { code: "no_dev_config", repo: repoKey, service: name, detail: { cwd } });
567
615
  }
@@ -585,9 +633,25 @@ export class ServiceRunner {
585
633
  });
586
634
  }
587
635
  }
636
+ // Gateway first, so the public port answers ("starting…") while the app
637
+ // boots and every later request — the user's browser, the agent's
638
+ // browser tools — reaches the app through it.
639
+ let appPort = port;
640
+ if (this.canFront(name)) {
641
+ await this.closeGateway(name);
642
+ appPort = await getFreePort();
643
+ const gw = new Gateway({ listenPort: port, upstreamPort: appPort, upstreamProtocol: svc.protocol, name, log: this.log });
644
+ try {
645
+ await gw.listen();
646
+ } catch (err) {
647
+ throw new PreviewError(`${name}: could not open the preview port ${port} for the gateway (${err?.message || err}).`, { code: "port_busy", repo: repoKey, service: name, detail: { port } });
648
+ }
649
+ this.gateways.set(name, gw);
650
+ }
651
+ m.appPort = appPort === port ? null : appPort;
588
652
  const env = {
589
653
  ...process.env,
590
- PORT: String(port),
654
+ PORT: String(appPort),
591
655
  ...Object.fromEntries(Object.entries(svc.env).map(([k, v]) => [k, substitutePorts(v, this.ports, this.serviceUrls)])),
592
656
  KAI_SESSION_ID: String(this.sessionId),
593
657
  BROWSER: "none",
@@ -600,6 +664,7 @@ export class ServiceRunner {
600
664
  const cmd = stripOpenFlag(substitutePorts(svc.run, this.ports, this.serviceUrls));
601
665
  const child = spawnShell(cmd, { cwd, env, fd, detached: true });
602
666
  m.ready = false;
667
+ let stoppedByUs = false;
603
668
  child.on("exit", (code) => {
604
669
  // An old shell can exit after a replacement has been admitted.
605
670
  if (this.processes.get(name) !== child) return;
@@ -608,6 +673,7 @@ export class ServiceRunner {
608
673
  this.log("info", "service.exit", { name, code });
609
674
  const deliberate = this.stopping.has(name);
610
675
  this.stopping.delete(name);
676
+ stoppedByUs = deliberate;
611
677
  if (deliberate) return;
612
678
  if (code && code !== 0) this.onStatus(`${name} exited with code ${code} — see ${logPath}`);
613
679
  if (!m.ready) return; // boot failure — reported by the boot path below
@@ -634,7 +700,7 @@ export class ServiceRunner {
634
700
  // Bail as soon as the process dies (command not found, crash on
635
701
  // boot) instead of polling a dead port for the full timeout.
636
702
  let ready = await Promise.race([
637
- waitForReady({ port, health: svc.health, timeoutMs: svc.readyTimeoutMs, protocol: svc.protocol, onTick: (s) => this.onStatus(`Still starting ${name} (${s}s)…`) }),
703
+ waitForReady({ port: appPort, health: svc.health, timeoutMs: svc.readyTimeoutMs, protocol: svc.protocol, onTick: (s) => this.onStatus(`Still starting ${name} (${s}s)…`), isCancelled: () => this.processes.get(name) !== child }),
638
704
  new Promise((resolveP) => child.once("exit", () => setTimeout(() => resolveP(false), 300))),
639
705
  ]);
640
706
  if (ready && this.settleMs > 0) {
@@ -642,19 +708,24 @@ export class ServiceRunner {
642
708
  // request (a missing env var, a DB refusing). Give them a moment and
643
709
  // look again before calling the preview "running".
644
710
  await new Promise((r) => setTimeout(r, this.settleMs));
645
- const alive = this.processes.has(name) && (await isPortListening(port));
711
+ const alive = this.processes.has(name) && (await isPortListening(appPort));
646
712
  if (!alive) ready = false;
647
713
  }
648
714
  m.ready = ready;
649
715
  if (!ready) {
650
716
  const tail = this.readLog(logPath);
651
717
  const classified = classifyLogTail(tail);
652
- const envKeys = classified ? [] : missingEnvKeys(cwd, { exists: existsSync, read: (p) => readFileSync(p, "utf8"), env });
718
+ // The `.env.example` guess only when it can be the cause: not for a
719
+ // service the bridge itself stopped mid-boot, and not for one that
720
+ // already came up with this very env earlier in this preview (a
721
+ // dashboard with 25 optional keys missing boots fine — 2026-09-21).
722
+ const envKeys = classified || stoppedByUs || m.everReady ? [] : missingEnvKeys(cwd, { exists: existsSync, read: (p) => readFileSync(p, "utf8"), env });
653
723
  const errorCode = classified?.code ?? (envKeys.length ? "env_missing" : "service_crashed");
654
- const error = classified?.line ?? (envKeys.length ? `${name} did not start — ${envKeys.length} key${envKeys.length === 1 ? "" : "s"} from .env.example missing: ${envKeys.join(", ")}` : logTailSummary(tail) || `${name} did not become ready within ${Math.round(svc.readyTimeoutMs / 1000)}s`);
724
+ const error = classified?.line ?? (envKeys.length ? `${name} did not start — ${envKeys.length} key${envKeys.length === 1 ? "" : "s"} from .env.example missing: ${envKeys.join(", ")}` : (stoppedByUs ? `${name} was stopped while it was starting` : logTailSummary(tail)) || `${name} did not become ready within ${Math.round(svc.readyTimeoutMs / 1000)}s`);
655
725
  this.onStatus(`${name} did not become ready — ${error} (see ${logPath})`);
656
726
  return { ...this.describeService(name), ready: false, error, errorCode, errorDetail: classified?.detail ?? (envKeys.length ? { envKeys } : null) };
657
727
  }
728
+ m.everReady = true;
658
729
  this.onStatus(`${name} ready on ${svc.protocol}://localhost:${port}`);
659
730
  await this.probeKind(m, port);
660
731
  return this.describeService(name);
@@ -710,7 +781,7 @@ export class ServiceRunner {
710
781
  return (await isPortListening(port)) ? null : port;
711
782
  }
712
783
 
713
- /** `[{ name, url, repoKey, kind, protocol, openapi, repoRoot, adopted }]` for every service this runner owns — the login flows' origin table. */
784
+ /** `[{ name, url, repoKey, kind, protocol, openapi, repoRoot, adopted }]` for every service this runner owns. */
714
785
  describeServices() {
715
786
  return Object.entries(this.ports).map(([name, port]) => {
716
787
  const m = this.meta.get(name);
@@ -723,11 +794,6 @@ export class ServiceRunner {
723
794
  return (this.registered.get(repoRoot) || []).map((s) => s.name);
724
795
  }
725
796
 
726
- /** Any service on https? → the browser needs `--ignore-https-errors`. */
727
- usesHttps() {
728
- return [...this.meta.values()].some((m) => m.protocol === "https");
729
- }
730
-
731
797
  /**
732
798
  * Install JS dependencies when the service dir has a package.json but
733
799
  * no node_modules (fresh worktree) or the lockfile changed since our
@@ -815,10 +881,11 @@ export class ServiceRunner {
815
881
 
816
882
  /** Kill one service's process tree; resolves when the process is gone (bounded). */
817
883
  async stopService(name, { timeoutMs = 5_000 } = {}) {
884
+ await this.closeGateway(name);
818
885
  const child = this.processes.get(name);
819
- if (!child) return;
886
+ if (!child) return this.stopResumed(name, { timeoutMs });
820
887
  this.stopping.add(name);
821
- const port = this.ports[name];
888
+ const port = this.meta.get(name)?.appPort ?? this.ports[name];
822
889
  let timer;
823
890
  const gone = child.exitCode !== null || child.signalCode !== null
824
891
  ? Promise.resolve()
@@ -856,10 +923,114 @@ export class ServiceRunner {
856
923
  const forceDeadline = Date.now() + 1000;
857
924
  while (port && await isPortListening(port) && Date.now() < forceDeadline) await new Promise(r => setTimeout(r, 50));
858
925
  if (port && await isPortListening(port)) throw new PreviewError(`${name} did not release port ${port}; retry after checking the service.`, { code: 'port_busy', service: name });
926
+ // The stop may have won on the 5 s timer, before the exit handler ran:
927
+ // leave nothing behind that lets the next boot mistake it for running.
928
+ if (this.processes.get(name) === child) {
929
+ this.processes.delete(name);
930
+ this.onProcess(name, child.pid, "remove");
931
+ }
932
+ this.stopping.delete(name);
933
+ const m = this.meta.get(name);
934
+ if (m) m.ready = false;
935
+ }
936
+
937
+ /** End a dev server re-adopted from a previous daemon life (pid only, no ChildProcess). */
938
+ async stopResumed(name, { timeoutMs = 5_000 } = {}) {
939
+ const pid = this.resumedPids.get(name);
940
+ if (!pid) return;
941
+ this.resumedPids.delete(name);
942
+ const port = this.meta.get(name)?.appPort ?? this.ports[name];
943
+ killTree(pid, "SIGTERM");
944
+ this.log("info", "service.stop", { name, pid, resumed: true });
945
+ const deadline = Date.now() + timeoutMs;
946
+ while (port && (await isPortListening(port)) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50));
947
+ if (port && (await isPortListening(port))) killTree(pid, "SIGKILL");
948
+ const m = this.meta.get(name);
949
+ if (m) m.ready = false;
950
+ }
951
+
952
+ /**
953
+ * Let the dev servers OUTLIVE this daemon: close the in-process gateways
954
+ * (the next daemon re-binds the public ports) and forget the processes
955
+ * without ending them. `snapshot()` is what the next daemon resumes from.
956
+ */
957
+ detach() {
958
+ for (const gw of this.gateways.values()) void gw.close().catch(() => {});
959
+ this.gateways.clear();
960
+ for (const child of this.processes.values()) {
961
+ child.removeAllListeners("exit");
962
+ child.unref?.();
963
+ }
964
+ this.processes.clear();
965
+ this.resumedPids.clear();
966
+ previewPorts.releaseSession(this.sessionId);
967
+ }
968
+
969
+ /** Everything the next daemon needs to re-adopt these services (JSON-safe). */
970
+ snapshot() {
971
+ const services = [];
972
+ for (const [name, port] of Object.entries(this.ports)) {
973
+ const m = this.meta.get(name);
974
+ if (!m) continue;
975
+ const adopted = this.adopted.has(name);
976
+ const pid = adopted ? null : this.processes.get(name)?.pid ?? this.resumedPids.get(name) ?? null;
977
+ if (!adopted && !pid) continue;
978
+ services.push({ name, pid, port, appPort: m.appPort ?? null, protocol: m.protocol ?? "http", kind: m.kind ?? "web", repoKey: m.repoKey ?? null, repoRoot: m.repoRoot, mode: m.mode, cwd: m.cwd, logPath: m.logPath, adopted, envSource: m.envSource ?? null, svc: m.svc, previewName: m.previewName ?? null, ready: !!m.ready });
979
+ }
980
+ return { services, previewNames: [...(this.previewNames ?? new Map())], warmed: !!this.warmed };
981
+ }
982
+
983
+ /**
984
+ * Re-adopt the services of a snapshot whose processes are still alive
985
+ * and listening: registers them exactly as `assignPorts` + `bootService`
986
+ * would, re-opens the gateways on their public ports. Returns the
987
+ * services that could NOT be resumed (dead pid, port silent) — the
988
+ * caller decides whether a partial resume is acceptable.
989
+ */
990
+ async resume(snapshot, { kill = process.kill } = {}) {
991
+ const failed = [];
992
+ for (const svc of snapshot?.services ?? []) {
993
+ const alive = svc.adopted ? true : (() => { try { kill(svc.pid, 0); return true; } catch { return false; } })();
994
+ const listening = await isPortListening(svc.appPort ?? svc.port);
995
+ if (!alive || !listening) {
996
+ failed.push({ name: svc.name, pid: svc.pid, alive, listening });
997
+ continue;
998
+ }
999
+ this.meta.set(svc.name, { svc: svc.svc, repoRoot: svc.repoRoot, repoKey: svc.repoKey, mode: svc.mode, cwd: svc.cwd, logPath: svc.logPath, kind: svc.kind, protocol: svc.protocol, ready: true, envSource: svc.envSource, previewName: svc.previewName, appPort: svc.appPort ?? null, pinned: false, kindProbed: true });
1000
+ this.ports[svc.name] = svc.port;
1001
+ this.serviceUrls[svc.name] = `${svc.protocol}://localhost:${svc.port}`;
1002
+ this.serviceRoots.set(svc.name, svc.repoRoot);
1003
+ if (svc.repoKey) this.repoKeys.set(svc.name, svc.repoKey);
1004
+ const list = this.registered.get(svc.repoRoot) ?? [];
1005
+ if (!list.some((x) => x.name === svc.name)) list.push(svc.svc);
1006
+ this.registered.set(svc.repoRoot, list);
1007
+ if (svc.adopted) this.adopted.add(svc.name);
1008
+ else this.resumedPids.set(svc.name, svc.pid);
1009
+ await previewPorts.reserve(`${this.sessionId}/${svc.repoRoot}/${svc.name}`, svc.port, true, async () => false).catch(() => {});
1010
+ if (svc.appPort && this.gatewayEnabled) {
1011
+ const gw = new Gateway({ listenPort: svc.port, upstreamPort: svc.appPort, upstreamProtocol: svc.protocol, name: svc.name, log: this.log });
1012
+ try {
1013
+ await gw.listen();
1014
+ this.gateways.set(svc.name, gw);
1015
+ } catch (err) {
1016
+ failed.push({ name: svc.name, pid: svc.pid, alive, listening, gateway: err?.message });
1017
+ }
1018
+ }
1019
+ }
1020
+ this.previewNames = new Map(snapshot?.previewNames ?? []);
1021
+ this.warmed = !!snapshot?.warmed;
1022
+ return failed;
859
1023
  }
860
1024
 
861
1025
  stopAll() {
862
1026
  previewPorts.releaseSession(this.sessionId);
1027
+ for (const gw of this.gateways.values()) void gw.close().catch(() => {});
1028
+ this.gateways.clear();
1029
+ for (const [name, pid] of this.resumedPids) {
1030
+ killTree(pid, "SIGTERM");
1031
+ this.log("info", "service.stop", { name, pid, resumed: true });
1032
+ }
1033
+ this.resumedPids.clear();
863
1034
  for (const [name, child] of this.processes) {
864
1035
  this.stopping.add(name);
865
1036
  if (process.platform === "win32") {
@@ -903,6 +1074,36 @@ export class ServiceRunner {
903
1074
  return out;
904
1075
  }
905
1076
 
1077
+ /**
1078
+ * Flip a LIVE runner between local and public addresses without a full
1079
+ * re-boot. `${url:x}` resolves to the public origin from now on (or back
1080
+ * to localhost with `null`); only the services whose env / run command
1081
+ * reference a `${url:…}` restart — a browser bundle bakes the origin in
1082
+ * at boot — on their SAME ports. An API, a worker, an adopted dev server
1083
+ * keep running. Returns the names that were restarted.
1084
+ */
1085
+ async setPublicHosts(publicHosts) {
1086
+ this.publicHosts = { ...(publicHosts || {}) };
1087
+ for (const [name, m] of this.meta) {
1088
+ const hostname = this.publicHosts[name];
1089
+ this.serviceUrls[name] = hostname ? `https://${hostname}` : `${m.protocol ?? "http"}://localhost:${this.ports[name]}`;
1090
+ }
1091
+ const restarted = [];
1092
+ for (const [name, m] of this.meta) {
1093
+ if (this.adopted.has(name) || !bakesServiceUrl(m.svc)) continue;
1094
+ this.log("info", "service.readdress", { name, publicUrl: this.serviceUrls[name] });
1095
+ await this.stopService(name);
1096
+ const started = await this.bootService(name);
1097
+ // Ready AND actually ours: a boot that returned early on a stale process entry is not a restart.
1098
+ if (!started.ready || !(this.processes.has(name) || this.resumedPids.has(name))) {
1099
+ throw new PreviewError(`${name} did not come back${started.error ? ` — ${started.error}` : ""}`, { code: started.errorCode || "service_crashed", repo: m.repoKey, service: name, detail: { ...(started.errorDetail || {}), logPath: started.logPath } });
1100
+ }
1101
+ restarted.push(name);
1102
+ }
1103
+ if (restarted.length) this.warmed = false; // a restarted dev server compiles again
1104
+ return restarted;
1105
+ }
1106
+
906
1107
  /** Prompt section telling the agent what is running + how to verify. */
907
1108
  describeForAgent(started) {
908
1109
  if (!started || started.services.length === 0) return "";
@@ -968,7 +1169,7 @@ export function browserInstallCommand({ playwrightCli, withDeps = false } = {})
968
1169
  }
969
1170
 
970
1171
  /**
971
- * Make sure SOME browser can launch before a verify turn: system Chrome
1172
+ * Make sure SOME browser can launch for the preview (warm-up, browser MCP): system Chrome
972
1173
  * wins; otherwise the bundled Chromium must be in Playwright's cache —
973
1174
  * and nothing installs it today (the `@playwright/mcp` dependency ships
974
1175
  * no browser), so download it once (`playwright install chromium`, 5-min
@@ -1038,36 +1239,25 @@ function defaultCanSudo() {
1038
1239
  }
1039
1240
 
1040
1241
  /**
1041
- * The Playwright MCP server registration for the turn (stdio, headless,
1042
- * always `--isolated`: the profile lives in memory). Verify turns add
1043
- * `outputDir` (recordings + screenshots land there for the host to
1044
- * upload), `caps` (`devtools` = video/tracing, `testing` = browser_verify_*,
1045
- * `storage` = browser_storage_state for the final sign-in refresh), an
1046
- * optional `secretsFile` (dotenv; the MCP substitutes secret NAMES typed
1047
- * into fields and masks the values), and — when a saved sign-in is
1048
- * injected — `storageStateFile` (re-read by the MCP on every new context,
1049
- * so it must outlive the turn) plus `allowedOrigins` (every other request
1050
- * is aborted: the injected cookies can only ever be sent to the preview).
1051
- * `ignoreHttpsErrors` for `protocol: https` dev servers (self-signed).
1052
- * `disabledTools` rides on the entry for the runner (Claude
1053
- * `disallowedTools`, Codex `disabled_tools`) — `browser_set_storage_state`
1054
- * is off so the agent can never load a state file of its choosing.
1242
+ * The Playwright MCP server registration for a coding turn with a live
1243
+ * preview (stdio, headless, always `--isolated`: the profile lives in
1244
+ * memory and is thrown away with the turn).
1055
1245
  */
1056
- export function previewMcpServer(runnerDir, { outputDir, caps, secretsFile, browser, storageStateFile, allowedOrigins, disabledTools, ignoreHttpsErrors } = {}) {
1057
- const args = [playwrightPaths(runnerDir).mcpCli, "--headless", "--isolated"];
1058
- args.push("--viewport-size", "1280x800");
1059
- const capList = Array.isArray(caps) ? caps : typeof caps === "string" && caps ? caps.split(",") : [];
1060
- if (capList.length > 0) args.push(`--caps=${capList.map((c) => String(c).trim()).filter(Boolean).join(",")}`);
1061
- if (outputDir) args.push("--output-dir", outputDir);
1062
- if (secretsFile) args.push("--secrets", secretsFile);
1063
- if (storageStateFile) args.push("--storage-state", storageStateFile);
1064
- const origins = (Array.isArray(allowedOrigins) ? allowedOrigins : []).map((o) => String(o).trim()).filter(Boolean);
1065
- if (origins.length > 0) args.push("--allowed-origins", [...new Set(origins)].join(";"));
1066
- if (ignoreHttpsErrors) args.push("--ignore-https-errors");
1246
+ /** Playwright MCP per-call ceilings (see the args below). */
1247
+ export const MCP_NAVIGATION_TIMEOUT_MS = 240_000;
1248
+ export const MCP_ACTION_TIMEOUT_MS = 45_000;
1249
+
1250
+ export function previewMcpServer(runnerDir, { browser } = {}) {
1251
+ const args = [playwrightPaths(runnerDir).mcpCli, "--headless", "--isolated", "--viewport-size", "1280x800"];
1252
+ // A dev build of a big app (thousands of unbundled modules, measured
1253
+ // 2026-09-15 on the Gleap dashboard: DOMContentLoaded 50-90 s in a fresh
1254
+ // profile) blows through the MCP's defaults — 60 s per navigation and 5 s
1255
+ // per action (that 5 s is also the screenshot ceiling: "waiting for fonts
1256
+ // to load"). The agent then burns its run on timeouts instead of
1257
+ // testing. Give both room; a fast app never notices.
1258
+ args.push("--timeout-navigation", String(MCP_NAVIGATION_TIMEOUT_MS));
1259
+ args.push("--timeout-action", String(MCP_ACTION_TIMEOUT_MS));
1067
1260
  const channel = browser === undefined ? resolvePreviewBrowser() : browser;
1068
1261
  if (channel) args.push("--browser", channel);
1069
- const entry = { id: "gleap_preview", name: "gleap_preview", command: process.execPath, args };
1070
- const disabled = (Array.isArray(disabledTools) ? disabledTools : []).map((t) => String(t).trim()).filter(Boolean);
1071
- if (disabled.length > 0) entry.disabledTools = disabled;
1072
- return entry;
1262
+ return { id: "gleap_preview", name: "gleap_preview", command: process.execPath, args };
1073
1263
  }
package/src/service.mjs CHANGED
@@ -60,13 +60,6 @@ ${envXml}
60
60
  `;
61
61
  }
62
62
 
63
- /** The desktop-session variables a headed browser needs (Linux only). */
64
- export function displayEnv(env = process.env) {
65
- const out = {};
66
- for (const k of ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"]) if (env[k]) out[k] = env[k];
67
- return out;
68
- }
69
-
70
63
  export function renderSystemdUnit({ program, args, env = {} }) {
71
64
  const q = (s) => `"${String(s).replace(/"/g, '\\"')}"`;
72
65
  return `[Unit]
@@ -102,10 +95,6 @@ export function install({ binPath, logDir, env = {} }) {
102
95
  PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
103
96
  HOME: homedir(),
104
97
  ...(process.env.SSH_AUTH_SOCK ? { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK } : {}),
105
- // Linux: a systemd --user unit starts without the desktop session's
106
- // display — the headed sign-in window (preview-login.mjs) needs it.
107
- // Captured at install time, like PATH.
108
- ...(os === "linux" ? displayEnv(process.env) : {}),
109
98
  ...env,
110
99
  };
111
100
  if (os === "darwin") {