Skip to content

feat(cli): preview --detach — a background server that manages its own lifecycle#2132

Open
WaterrrForever wants to merge 3 commits into
mainfrom
feat/preview-detach
Open

feat(cli): preview --detach — a background server that manages its own lifecycle#2132
WaterrrForever wants to merge 3 commits into
mainfrom
feat/preview-detach

Conversation

@WaterrrForever

@WaterrrForever WaterrrForever commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

What

preview --detach starts the embedded studio as a detached child, waits for it to report ready, prints the URL plus a ready-to-hand project link, opens the browser, and returns — the command exiting no longer takes the server with it. The server then watches its own lifecycle instead of a terminal: an idle timeout, plus an optional owner pid. preview --stop stops this project's server (per-project counterpart to the existing --kill-all).

Changes

  • server/detachedPreview.ts (new) — the parent↔child handshake and the lifecycle policy:
    • State file .hyperframes/preview/server.json keyed by a per-launch nonce, log at server.log — a stale file from a previous launch is never mistaken for this one.
    • waitForServerState re-reads once after noticing the child exited, so the reuse path (write state, exit immediately) isn't misread as a startup failure.
    • detachedShutdownReason (pure) — exit when the explicit --owner-pid is gone, or after an idle hour (HYPERFRAMES_PREVIEW_IDLE_TIMEOUT_MS to tune, 0 disables). Deliberately no process-tree guessing for a default owner: the pid chain through npx varies by harness, and a wrong guess kills the server under the user.
  • commands/preview.ts:
    • --detach (parent): spawn a detached child (--detached-child, internal), wait on the state file, print the summary + Open link (--json for the machine shape), open the browser, exit. On failure, print the log tail instead of a bare error.
    • Detached child: reports through the state file in both branches — fresh server, or already-running reuse (then exits; the existing server's lifecycle stays with whoever started it). Request activity feeds the idle clock; a 30 s watcher SIGTERMs into the existing graceful-shutdown path, which also removes the state file.
    • --stop: SIGTERM the server(s) whose projectDir matches this project.
  • Docshyperframes-cli preview-render.md: the new flags + "From an agent, prefer --detach" guidance; skills-manifest regenerated.

Guardrails

  • Nothing changes unless --detach is passed — plain preview keeps today's foreground behavior exactly; under the Vite dev/local-studio modes --detach warns and runs foreground.
  • The idle/owner watcher logs its reason before SIGTERM-ing itself.
  • clearServerState only deletes its own launch's file — an older child shutting down can never clobber a newer server's state.

Tests / verification

  • 1445/1445 cli tests green (16 new: state-file round-trip, corrupt-file tolerance, the write-then-exit race, stale-launch rejection, the shutdown-reason matrix, idle-limit env parsing).
  • E2E smoke on the built CLI: --detach --json returns once serving with the full state payload; the API answers; --stop SIGTERMs it and graceful shutdown removes the state file.
  • typecheck, oxlint, oxfmt clean.

…n lifecycle

Agent sessions start preview as a background task, tying the server's
lifetime to the task runner in both directions: reaped mid-session, the
URL already handed to the user goes dead; never reaped, stale servers
pile up serving old bundles. --detach starts the embedded studio as a
detached child, waits for it to report ready through
.hyperframes/preview/server.json, prints the URL + project link, opens
the browser, and returns — the command exiting no longer takes the
server with it.

The detached server watches its own lifecycle instead of a terminal: an
idle-hour timeout (HYPERFRAMES_PREVIEW_IDLE_TIMEOUT_MS, 0 disables) plus
an optional explicit --owner-pid — no process-tree guessing for a
default owner. --stop stops this project's server (per-project
counterpart to --kill-all). Plain foreground preview is unchanged.

Shape borrowed from Every's compound-engineering visual-probe helper
(detached start + state-file handshake + self-watching lifecycle).
Comment thread packages/cli/src/commands/preview.ts Fixed
CodeQL (js/file-system-race): the log file could change between
existsSync and readFileSync. Read inside try/catch instead — a missing
log just means the child died before writing anything.
Comment thread packages/cli/src/commands/preview.ts Fixed
CodeQL (js/file-system-race) still paired the by-path read with the
earlier append-open. Open the log once for reading and read through the
descriptor — the open is the only path operation, and a missing log
still just means the child died before writing anything.
let tail = "";
try {
// Open once and read through the descriptor — no path re-check to race.
const tailFd = openSync(paths.log, "r");
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants