diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 598d6e92e9..c23adf8fb8 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -5,6 +5,8 @@ import type { Readable } from "node:stream"; export const examples: Example[] = [ ["Preview the current project", "hyperframes preview"], + ["Start detached — returns once serving, server keeps running", "hyperframes preview --detach"], + ["Stop the preview server for this project", "hyperframes preview --stop"], ["Print the current Studio selection as JSON", "hyperframes preview --selection --json"], ["Print current Studio context as JSON", "hyperframes preview --context --json"], ["Preview a specific project directory", "hyperframes preview ./my-video"], @@ -19,7 +21,18 @@ export const examples: Example[] = [ ["List all active preview servers", "hyperframes preview --list"], ["Kill all active preview servers", "hyperframes preview --kill-all"], ]; -import { existsSync, lstatSync, symlinkSync, unlinkSync, readlinkSync, mkdirSync } from "node:fs"; +import { + closeSync, + existsSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + unlinkSync, +} from "node:fs"; import { resolve, dirname, basename, join } from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; @@ -44,6 +57,15 @@ import { } from "../server/portUtils.js"; import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js"; import { resolveProject } from "../utils/project.js"; +import { + clearServerState, + detachedShutdownReason, + previewStatePaths, + processAlive, + resolveIdleLimitMs, + waitForServerState, + writeServerState, +} from "../server/detachedPreview.js"; interface BrowserLaunchOptions { noOpen?: boolean; @@ -58,6 +80,8 @@ interface StudioLaunchOptions extends BrowserLaunchOptions { interface EmbeddedStudioOptions extends StudioLaunchOptions { forceNew?: boolean; + /** Present when this process is the child of `preview --detach`. */ + detached?: { launchId: string; ownerPid: number | null }; } type StudioChildProcess = ChildProcessByStdio; @@ -89,6 +113,26 @@ export default defineCommand({ description: "Start a new server even if one is already running for this project", default: false, }, + detach: { + type: "boolean", + description: + "Start the server detached from this command — returns once it's serving and prints the URL; the server shuts itself down after an idle hour", + default: false, + }, + stop: { + type: "boolean", + description: "Stop the preview server for this project and exit", + default: false, + }, + "owner-pid": { + type: "string", + description: "(with --detach) shut the server down when this process exits", + }, + "detached-child": { + type: "boolean", + description: "(internal) run as the server child of --detach", + default: false, + }, list: { type: "boolean", description: "List all active preview servers and exit", @@ -177,6 +221,33 @@ export default defineCommand({ return; } + // --stop: kill the server(s) for this project only + if (args.stop) { + const project = resolveProject(args.dir); + const normalize = (p: string) => resolve(p).replace(/\\/g, "/").toLowerCase(); + const servers = (await scanActiveServers(startPort)).filter( + (s) => normalize(s.projectDir) === normalize(project.dir), + ); + if (servers.length === 0) { + console.log("\n No preview server running for this project.\n"); + return; + } + let stopped = 0; + for (const server of servers) { + if (!server.pid) continue; + try { + process.kill(parseInt(server.pid, 10), "SIGTERM"); + stopped++; + } catch { + // Already gone. + } + } + console.log( + `\n Stopped ${stopped} preview server${stopped === 1 ? "" : "s"} for ${project.name}.\n`, + ); + return; + } + if (args.context) { const project = resolveProject(args.dir); return printCurrentContext(project.dir, startPort, { @@ -251,28 +322,52 @@ export default defineCommand({ return; } - if (isDevMode()) { - return runDevMode(dir, { - projectName, - noOpen, - browserPath, - userDataDir, - remoteDebuggingPort, - }); + const detachedChild = !!args["detached-child"]; + const rawOwnerPid = args["owner-pid"] as string | undefined; + const ownerPid = rawOwnerPid === undefined ? null : Number.parseInt(rawOwnerPid, 10); + if (rawOwnerPid !== undefined && (!Number.isInteger(ownerPid) || (ownerPid as number) <= 1)) { + clack.log.error("--owner-pid must be an integer greater than 1"); + process.exitCode = 1; + return; + } + + // The detached child always serves embedded — the parent only spawns it + // after ruling the Vite modes out. + if (!detachedChild) { + if (isDevMode() || hasLocalStudio(dir)) { + if (args.detach) { + clack.log.warn( + "--detach only supports the embedded studio — starting in the foreground.", + ); + } + const launchOptions = { + projectName, + noOpen, + browserPath, + userDataDir, + remoteDebuggingPort, + }; + return isDevMode() + ? runDevMode(dir, launchOptions) + : runLocalStudioMode(dir, launchOptions); + } } - // If @hyperframes/studio is installed locally, use Vite for full HMR - if (hasLocalStudio(dir)) { - return runLocalStudioMode(dir, { + const forceNew = !!args["force-new"]; + + if (args.detach && !detachedChild) { + return runDetachedParent(dir, startPort, { projectName, + forceNew, noOpen, browserPath, userDataDir, remoteDebuggingPort, + ownerPid, + json: Boolean(args.json), }); } - const forceNew = !!args["force-new"]; return runEmbeddedMode(dir, startPort, { projectName, forceNew, @@ -280,6 +375,14 @@ export default defineCommand({ browserPath, userDataDir, remoteDebuggingPort, + ...(detachedChild + ? { + detached: { + launchId: process.env.HYPERFRAMES_PREVIEW_LAUNCH_ID || "manual", + ownerPid, + }, + } + : {}), }); }, }); @@ -635,9 +738,14 @@ function compactSelectionPayload(selection: StudioSelectionSnapshot): CompactSel }; } +// The full Studio URL to open or hand to the user: the project hash route. +function studioDeepLink(url: string, projectName: string): string { + return `${url}#project/${projectName}`; +} + function openStudioBrowser(url: string, projectName: string, options?: BrowserLaunchOptions): void { if (options?.noOpen) return; - openBrowser(`${url}#project/${projectName}`, { + openBrowser(studioDeepLink(url, projectName), { browserPath: options?.browserPath, userDataDir: options?.userDataDir, remoteDebuggingPort: options?.remoteDebuggingPort, @@ -825,6 +933,99 @@ async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): P return waitForChildClose(child); } +/** + * Detached mode, launching side: spawn this same CLI as a detached child + * (`--detached-child`), wait for it to report through the state file, then + * print the URLs and return. The command exiting no longer takes the server + * with it — the child watches its own lifecycle instead (idle timeout, plus an + * optional --owner-pid). Borrowed from Compound Engineering's visual-probe + * server, adapted to the studio's "an open tab counts as activity" semantics. + */ +async function runDetachedParent( + dir: string, + startPort: number, + options: EmbeddedStudioOptions & { ownerPid: number | null; json: boolean }, +): Promise { + const { randomUUID } = await import("node:crypto"); + const launchId = randomUUID(); + const paths = previewStatePaths(dir); + mkdirSync(paths.dir, { recursive: true }); + rmSync(paths.state, { force: true }); + + const spinner = options.json ? null : clack.spinner(); + if (!options.json) clack.intro(c.bold("hyperframes preview") + c.dim(" (detached)")); + spinner?.start("Starting studio in the background..."); + + const logFd = openSync(paths.log, "a"); + const cliEntry = process.argv[1]; + if (!cliEntry) { + spinner?.stop(c.error("Failed to start")); + console.error(" Could not resolve the CLI entry point for the detached child."); + process.exitCode = 1; + return; + } + const childArgs = [cliEntry, "preview", dir, "--port", String(startPort), "--detached-child"]; + if (options.forceNew) childArgs.push("--force-new"); + if (options.ownerPid !== null) childArgs.push("--owner-pid", String(options.ownerPid)); + const child = spawn(process.execPath, childArgs, { + detached: true, + stdio: ["ignore", logFd, logFd], + env: { ...process.env, HYPERFRAMES_PREVIEW_LAUNCH_ID: launchId }, + }); + let childExited = false; + child.once("exit", () => { + childExited = true; + }); + child.unref(); + closeSync(logFd); + + const state = await waitForServerState(dir, launchId, { childAlive: () => !childExited }); + if (!state) { + spinner?.stop(c.error("Detached studio failed to start")); + if (options.json) { + console.log( + JSON.stringify({ ok: false, error: "preview did not report ready", log: paths.log }), + ); + } else { + let tail = ""; + try { + // Open once and read through the descriptor — no path re-check to race. + const tailFd = openSync(paths.log, "r"); + try { + tail = readFileSync(tailFd, "utf-8").trimEnd().split("\n").slice(-12).join("\n"); + } finally { + closeSync(tailFd); + } + } catch { + // No log yet — the child died before writing anything. + } + console.error(); + if (tail) console.error(tail); + console.error(); + console.error(` ${c.dim("Full log:")} ${paths.log}`); + console.error(); + } + process.exitCode = 1; + return; + } + + spinner?.stop( + c.success(state.status === "already-running" ? "Already running" : "Studio running (detached)"), + ); + if (options.json) { + console.log(JSON.stringify({ ok: true, ...state, log: paths.log }, null, 2)); + } else { + printStudioSummary(state.projectName, state.url, { + details: [ + `Open ${state.boardUrl}`, + "The server keeps running after this command returns.", + ], + footer: "Stop it with: hyperframes preview --stop", + }); + } + openStudioBrowser(state.url, state.projectName, options); +} + /** * Embedded mode: serve the pre-built studio SPA with a standalone Hono server. * Works without any additional dependencies — the studio is bundled in dist/. @@ -864,10 +1065,21 @@ async function runEmbeddedMode( const { app } = createStudioServer({ projectDir: dir, projectName: pName }); const serverBuildSignature = await loadPreviewServerBuildSignature(); + // Detached servers track request activity: an open Studio tab polls the + // project signature, so "idle" only starts once nobody is looking. + const detached = options?.detached ?? null; + let lastActivityMs = Date.now(); + const fetchHandler: typeof app.fetch = detached + ? (...fetchArgs: Parameters) => { + lastActivityMs = Date.now(); + return app.fetch(...fetchArgs); + } + : app.fetch; + let result: FindPortResult; try { result = await findPortAndServe( - app.fetch, + fetchHandler, startPort, dir, !!options?.forceNew, @@ -885,6 +1097,21 @@ async function runEmbeddedMode( if (result.type === "already-running") { const url = `http://localhost:${result.port}`; s.stop(c.success("Already running")); + if (detached) { + // Report the existing server back to the launching process and exit — + // its lifecycle belongs to whoever started it. + writeServerState(dir, { + launchId: detached.launchId, + status: "already-running", + port: result.port, + url, + boardUrl: studioDeepLink(url, pName), + pid: null, + projectName: pName, + projectDir: dir, + startedAt: new Date().toISOString(), + }); + } printStudioSummary(pName, url, { details: ["Reusing existing server. Use --force-new to start a fresh instance."], }); @@ -904,10 +1131,38 @@ async function runEmbeddedMode( "Edit with your AI agent — it has HyperFrames skills installed.", "Changes reload automatically in the studio.", ], - footer: "Press Ctrl+C to stop", + footer: detached ? "Detached — stop with: hyperframes preview --stop" : "Press Ctrl+C to stop", }); openStudioBrowser(url, pName, options); + if (detached) { + writeServerState(dir, { + launchId: detached.launchId, + status: "started", + port: result.port, + url, + boardUrl: studioDeepLink(url, pName), + pid: process.pid, + projectName: pName, + projectDir: dir, + startedAt: new Date().toISOString(), + }); + const idleLimitMs = resolveIdleLimitMs(process.env.HYPERFRAMES_PREVIEW_IDLE_TIMEOUT_MS); + const lifecycleTimer = setInterval(() => { + const reason = detachedShutdownReason({ + ownerPid: detached.ownerPid, + ownerAlive: processAlive, + idleMs: Date.now() - lastActivityMs, + idleLimitMs, + }); + if (!reason) return; + clearInterval(lifecycleTimer); + console.log(` Detached preview shutting down: ${reason}.`); + process.kill(process.pid, "SIGTERM"); + }, 30_000); + lifecycleTimer.unref(); + } + // Block until Ctrl+C. Node would normally exit on SIGINT, but the listening // HTTP server keeps handles open, so the event loop stays alive after the // signal handler fires. Close the server explicitly and resolve the promise @@ -919,7 +1174,7 @@ async function runEmbeddedMode( // interface on stdin so the keystroke is observed at the TTY layer and // re-emit it as SIGINT. No-op on platforms where the signal already arrives. let rl: import("node:readline").Interface | undefined; - if (process.platform === "win32") { + if (process.platform === "win32" && !detached) { const readline = await import("node:readline"); rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.on("SIGINT", () => { @@ -935,6 +1190,13 @@ async function runEmbeddedMode( process.off("SIGINT", shutdown); process.off("SIGTERM", shutdown); rl?.close(); + if (detached) { + try { + clearServerState(dir, detached.launchId); + } catch { + /* state file cleanup is best-effort */ + } + } console.log(); console.log(` ${c.dim("Shutting down studio...")}`); diff --git a/packages/cli/src/server/detachedPreview.test.ts b/packages/cli/src/server/detachedPreview.test.ts new file mode 100644 index 0000000000..7cf2de5918 --- /dev/null +++ b/packages/cli/src/server/detachedPreview.test.ts @@ -0,0 +1,149 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + clearServerState, + detachedShutdownReason, + previewStatePaths, + readServerState, + resolveIdleLimitMs, + waitForServerState, + writeServerState, + type DetachedServerState, +} from "./detachedPreview.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function tempProject(): string { + const dir = mkdtempSync(join(tmpdir(), "hf-detached-test-")); + tempDirs.push(dir); + return dir; +} + +function makeState(overrides: Partial = {}): DetachedServerState { + return { + launchId: "launch-1", + status: "started", + port: 3002, + url: "http://localhost:3002", + boardUrl: "http://localhost:3002/?view=storyboard#project/demo", + pid: 1234, + projectName: "demo", + projectDir: "/tmp/demo", + startedAt: "2026-07-10T00:00:00.000Z", + ...overrides, + }; +} + +describe("server state file round-trip", () => { + it("writes and reads back the state", () => { + const dir = tempProject(); + writeServerState(dir, makeState()); + expect(readServerState(dir)).toMatchObject({ launchId: "launch-1", port: 3002 }); + }); + + it("returns null for a missing or corrupt state file", () => { + const dir = tempProject(); + expect(readServerState(dir)).toBeNull(); + const paths = previewStatePaths(dir); + mkdirSync(paths.dir, { recursive: true }); + writeFileSync(paths.state, "not json"); + expect(readServerState(dir)).toBeNull(); + }); + + it("clearServerState only removes its own launch's file", () => { + const dir = tempProject(); + writeServerState(dir, makeState({ launchId: "newer-launch" })); + clearServerState(dir, "old-launch"); + expect(readServerState(dir)).not.toBeNull(); + clearServerState(dir, "newer-launch"); + expect(readServerState(dir)).toBeNull(); + }); +}); + +describe("waitForServerState", () => { + it("resolves once the matching state lands", async () => { + const dir = tempProject(); + setTimeout(() => writeServerState(dir, makeState()), 50); + const state = await waitForServerState(dir, "launch-1", { + timeoutMs: 2000, + childAlive: () => true, + }); + expect(state?.port).toBe(3002); + }); + + it("re-reads after a child exit so write-then-exit is not a failure", async () => { + const dir = tempProject(); + writeServerState(dir, makeState({ status: "already-running", pid: null })); + const state = await waitForServerState(dir, "launch-1", { + timeoutMs: 2000, + childAlive: () => false, + }); + expect(state?.status).toBe("already-running"); + }); + + it("fails fast when the child dies without reporting", async () => { + const dir = tempProject(); + const state = await waitForServerState(dir, "launch-1", { + timeoutMs: 2000, + childAlive: () => false, + }); + expect(state).toBeNull(); + }); + + it("ignores a stale state file from a previous launch", async () => { + const dir = tempProject(); + writeServerState(dir, makeState({ launchId: "previous-launch" })); + const state = await waitForServerState(dir, "launch-2", { + timeoutMs: 300, + childAlive: () => true, + }); + expect(state).toBeNull(); + }); +}); + +describe("detachedShutdownReason", () => { + const HOUR = 60 * 60 * 1000; + + it("keeps serving while active and owned", () => { + expect( + detachedShutdownReason({ + ownerPid: 42, + ownerAlive: () => true, + idleMs: 5 * 60 * 1000, + idleLimitMs: HOUR, + }), + ).toBeNull(); + }); + + it("shuts down when the owner is gone", () => { + expect( + detachedShutdownReason({ + ownerPid: 42, + ownerAlive: () => false, + idleMs: 0, + idleLimitMs: HOUR, + }), + ).toContain("owner process 42"); + }); + + it("shuts down past the idle limit, but never with the limit disabled", () => { + const base = { ownerPid: null, ownerAlive: () => true }; + expect(detachedShutdownReason({ ...base, idleMs: HOUR, idleLimitMs: HOUR })).toContain("idle"); + expect(detachedShutdownReason({ ...base, idleMs: 10 * HOUR, idleLimitMs: 0 })).toBeNull(); + }); +}); + +describe("resolveIdleLimitMs", () => { + it("defaults to one hour and honors overrides including 0", () => { + expect(resolveIdleLimitMs(undefined)).toBe(60 * 60 * 1000); + expect(resolveIdleLimitMs("120000")).toBe(120000); + expect(resolveIdleLimitMs("0")).toBe(0); + expect(resolveIdleLimitMs("not-a-number")).toBe(60 * 60 * 1000); + }); +}); diff --git a/packages/cli/src/server/detachedPreview.ts b/packages/cli/src/server/detachedPreview.ts new file mode 100644 index 0000000000..38d1c89885 --- /dev/null +++ b/packages/cli/src/server/detachedPreview.ts @@ -0,0 +1,126 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Detached preview servers (`hyperframes preview --detach`) hand off between two + * processes: the launching CLI spawns a detached child, the child binds a port + * and reports back through a state file under `.hyperframes/preview/`. The + * `.hyperframes` directory is excluded from both the file watcher and the + * project signature, so this bookkeeping never triggers client refreshes. + */ + +export interface DetachedServerState { + /** Nonce minted by the launching process — pairs a state file to its launch. */ + launchId: string; + status: "started" | "already-running"; + port: number; + url: string; + /** Deep link to the view the browser should land on (board during planning). */ + boardUrl: string; + /** Server process id; null when an existing server was reused. */ + pid: number | null; + projectName: string; + projectDir: string; + startedAt: string; +} + +export function previewStatePaths(projectDir: string): { + dir: string; + state: string; + log: string; +} { + const dir = join(projectDir, ".hyperframes", "preview"); + return { dir, state: join(dir, "server.json"), log: join(dir, "server.log") }; +} + +export function writeServerState(projectDir: string, state: DetachedServerState): void { + const paths = previewStatePaths(projectDir); + mkdirSync(paths.dir, { recursive: true }); + writeFileSync(paths.state, `${JSON.stringify(state, null, 2)}\n`); +} + +export function readServerState(projectDir: string): DetachedServerState | null { + const paths = previewStatePaths(projectDir); + if (!existsSync(paths.state)) return null; + try { + const parsed: unknown = JSON.parse(readFileSync(paths.state, "utf-8")); + if (typeof parsed !== "object" || parsed === null) return null; + const state = parsed as DetachedServerState; + if (typeof state.launchId !== "string" || typeof state.port !== "number") return null; + return state; + } catch { + return null; + } +} + +/** Remove the state file, but only if it still belongs to `launchId`. */ +export function clearServerState(projectDir: string, launchId: string): void { + const current = readServerState(projectDir); + if (current?.launchId !== launchId) return; + rmSync(previewStatePaths(projectDir).state, { force: true }); +} + +/** + * Wait for the detached child to report readiness. Resolves null when the child + * dies first or the timeout passes — callers surface the log file then. + */ +export async function waitForServerState( + projectDir: string, + launchId: string, + opts: { timeoutMs?: number; childAlive: () => boolean }, +): Promise { + const readMatching = (): DetachedServerState | null => { + const state = readServerState(projectDir); + return state?.launchId === launchId ? state : null; + }; + const deadline = Date.now() + (opts.timeoutMs ?? 20_000); + while (Date.now() < deadline) { + const state = readMatching(); + if (state) return state; + // A child that reused an existing server writes its state and exits + // immediately — re-read once after noticing the exit so that write is + // never mistaken for a startup failure. + if (!opts.childAlive()) return readMatching(); + await new Promise((r) => setTimeout(r, 150)); + } + return null; +} + +export function processAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 1) return false; + try { + process.kill(pid, 0); + return true; + } catch (err) { + // EPERM means the process exists but belongs to someone else. + return (err as NodeJS.ErrnoException).code === "EPERM"; + } +} + +/** Idle limit for detached servers; 0 disables. Default one hour. */ +export function resolveIdleLimitMs(envValue: string | undefined): number { + const parsed = envValue === undefined ? Number.NaN : Number.parseInt(envValue, 10); + if (Number.isFinite(parsed) && parsed >= 0) return parsed; + return 60 * 60 * 1000; +} + +/** + * Decide whether a detached server should shut itself down. Returns the + * human-readable reason, or null to keep serving. An open Studio tab counts as + * activity (the storyboard polls the signature endpoint), so "idle" really + * means nobody — human or agent — is looking. + */ +export function detachedShutdownReason(opts: { + ownerPid: number | null; + ownerAlive: (pid: number) => boolean; + idleMs: number; + idleLimitMs: number; +}): string | null { + if (opts.ownerPid !== null && !opts.ownerAlive(opts.ownerPid)) { + return `owner process ${opts.ownerPid} exited`; + } + if (opts.idleLimitMs > 0 && opts.idleMs >= opts.idleLimitMs) { + return `idle for ${Math.round(opts.idleMs / 60_000)} minutes`; + } + return null; +} diff --git a/skills-manifest.json b/skills-manifest.json index 82e22e1c56..23d380e4e6 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -26,7 +26,7 @@ "files": 99 }, "hyperframes-cli": { - "hash": "8ca3bef87f169ba1", + "hash": "9be9b36924cc7997", "files": 7 }, "hyperframes-core": { diff --git a/skills/hyperframes-cli/references/preview-render.md b/skills/hyperframes-cli/references/preview-render.md index 212910fcef..b7442c3352 100644 --- a/skills/hyperframes-cli/references/preview-render.md +++ b/skills/hyperframes-cli/references/preview-render.md @@ -5,7 +5,9 @@ Serve, render, and share commands. ## preview ```bash -npx hyperframes preview # serve current directory +npx hyperframes preview # serve current directory (foreground, Ctrl+C to stop) +npx hyperframes preview --detach # serve in the background; returns once serving +npx hyperframes preview --stop # stop this project's server npx hyperframes preview --port 4567 # custom port (default 3002) npx hyperframes preview --selection --json # print the current Studio selection and exit npx hyperframes preview --context --json # print compact agent context from Studio @@ -13,6 +15,8 @@ npx hyperframes preview --context --json # print compact agent context from Stu Hot-reloads on file changes. Opens Studio in the browser automatically — the full timeline editor, where the user can play the video and edit anything by hand before rendering. This is the review surface, not just a viewer. +**From an agent, prefer `--detach`.** The command comes back as soon as the server is serving and prints both the base URL and the ready-to-hand project link (the `Open` line; `--json` for the machine shape) — no background task to babysit, and the server outlives the session that started it. A detached server watches its own lifecycle: it exits after an hour without HTTP requests, or as soon as a process named via `--owner-pid ` is gone. Tune the idle window with `HYPERFRAMES_PREVIEW_IDLE_TIMEOUT_MS` (`0` disables). It reports through `.hyperframes/preview/server.json` and logs to `.hyperframes/preview/server.log` — read the log when a detached start fails. + When handing a project back to the user, use the Studio project URL, not the source `index.html` path: ```text