From 84ff3dbf5a5983b919b65bd0fee5df66a8b243fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 14 Aug 2026 23:09:51 +0000 Subject: [PATCH 01/16] fix(studio): support boosted audio and persistent previews --- packages/cli/src/commands/preview.test.ts | 30 ++++- packages/cli/src/commands/preview.ts | 109 +++++++++++------- packages/cli/src/utils/studioProxyEnv.test.ts | 18 +++ packages/cli/src/utils/studioProxyEnv.ts | 14 +++ packages/core/package-subpaths.json | 6 + packages/core/package.json | 10 ++ packages/core/src/audioGain.test.ts | 36 ++++++ packages/core/src/audioGain.ts | 54 +++++++++ packages/core/src/runtime/init.ts | 2 +- packages/core/src/runtime/media.test.ts | 19 +++ packages/core/src/runtime/media.ts | 27 ++--- .../src/runtime/webAudioTransport.test.ts | 9 ++ .../core/src/runtime/webAudioTransport.ts | 5 +- .../engine/src/services/audioMixer.test.ts | 29 +++++ packages/engine/src/services/audioMixer.ts | 4 +- packages/parsers/src/types.ts | 2 +- .../propertyPanelFlatMediaSection.test.tsx | 20 ++-- .../editor/propertyPanelFlatMediaSection.tsx | 24 ++-- .../editor/propertyPanelMediaSection.tsx | 21 ++-- packages/studio/vite.config.ts | 9 ++ packages/studio/vite.preview-config.test.ts | 30 +++++ packages/studio/vite.preview-config.ts | 22 ++++ 22 files changed, 413 insertions(+), 87 deletions(-) create mode 100644 packages/core/src/audioGain.test.ts create mode 100644 packages/core/src/audioGain.ts create mode 100644 packages/studio/vite.preview-config.test.ts create mode 100644 packages/studio/vite.preview-config.ts diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts index 38b02f58e6..154d1039ea 100644 --- a/packages/cli/src/commands/preview.test.ts +++ b/packages/cli/src/commands/preview.test.ts @@ -2,7 +2,7 @@ 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 { studioLandingSearch } from "./preview.js"; +import { previewLaunchMode, previewViteArgs, studioLandingSearch } from "./preview.js"; const tempDirs: string[] = []; @@ -51,3 +51,31 @@ describe("studioLandingSearch", () => { expect(studioLandingSearch(dir)).toBe(""); }); }); + +describe("previewLaunchMode", () => { + it("keeps background preview persistent in monorepo dev mode", () => { + expect(previewLaunchMode({ background: true, devMode: true, localStudio: false })).toBe( + "background", + ); + }); + + it("keeps background preview persistent with a project-local Studio", () => { + expect(previewLaunchMode({ background: true, devMode: false, localStudio: true })).toBe( + "background", + ); + }); + + it("preserves the foreground launch preference", () => { + expect(previewLaunchMode({ background: false, devMode: true, localStudio: true })).toBe("dev"); + expect(previewLaunchMode({ background: false, devMode: false, localStudio: true })).toBe( + "local", + ); + expect(previewLaunchMode({ background: false, devMode: false, localStudio: false })).toBe( + "embedded", + ); + }); + + it("pins detached Vite to the port the lifecycle scanner waits on", () => { + expect(previewViteArgs(3032)).toEqual(["--host", "127.0.0.1", "--port", "3032"]); + }); +}); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 1c297427f4..02d495f9dc 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -83,6 +83,7 @@ interface StudioLaunchOptions extends BrowserLaunchOptions { projectName?: string; autoProxy?: boolean; browserGpuMode?: BrowserGpuMode; + port?: number; } interface EmbeddedStudioOptions extends StudioLaunchOptions { @@ -121,7 +122,7 @@ export default defineCommand({ }, background: { type: "boolean", - description: "Start an embedded preview that remains running after the command exits", + description: "Start a preview that remains running after the command exits", default: false, }, status: { @@ -352,42 +353,13 @@ export default defineCommand({ // modes all receive identical --proxy/--no-proxy + config semantics. const autoProxy = resolveAutoProxy(dir, args.proxy as boolean | undefined); - if (isDevMode()) { - if (args.background) { - clack.log.error("--background currently supports the embedded preview server only"); - setCommandExitCode(1); - return; - } - return runDevMode(dir, { - projectName, - noOpen, - browserPath, - userDataDir, - remoteDebuggingPort, - browserNoGpu, - autoProxy, - }); - } - - // If @hyperframes/studio is installed locally, use Vite for full HMR - if (hasLocalStudio(dir)) { - if (args.background) { - clack.log.error("--background currently supports the embedded preview server only"); - setCommandExitCode(1); - return; - } - return runLocalStudioMode(dir, { - projectName, - noOpen, - browserPath, - userDataDir, - remoteDebuggingPort, - browserNoGpu, - autoProxy, - }); - } + const launchMode = previewLaunchMode({ + background: Boolean(args.background), + devMode: isDevMode(), + localStudio: hasLocalStudio(dir), + }); - if (args.background) { + if (launchMode === "background") { let background; try { background = await startBackgroundPreview(dir, startPort, { @@ -420,6 +392,35 @@ export default defineCommand({ return; } + if (launchMode === "dev") { + return runDevMode(dir, { + projectName, + noOpen, + browserPath, + userDataDir, + remoteDebuggingPort, + browserNoGpu, + autoProxy, + browserGpuMode, + port: startPort, + }); + } + + // If @hyperframes/studio is installed locally, use Vite for full HMR + if (launchMode === "local") { + return runLocalStudioMode(dir, { + projectName, + noOpen, + browserPath, + userDataDir, + remoteDebuggingPort, + browserNoGpu, + autoProxy, + browserGpuMode, + port: startPort, + }); + } + const forceNew = !!args["force-new"]; return runEmbeddedMode(dir, startPort, { projectName, @@ -435,8 +436,24 @@ export default defineCommand({ }, }); -// `host` is the loopback the server actually bound (Vite binds `[::1]`, embedded -// binds `127.0.0.1`); default to IPv4 for the embedded/legacy callers. +export type PreviewLaunchMode = "background" | "dev" | "local" | "embedded"; + +export function previewLaunchMode(options: { + background: boolean; + devMode: boolean; + localStudio: boolean; +}): PreviewLaunchMode { + if (options.background) return "background"; + if (options.devMode) return "dev"; + return options.localStudio ? "local" : "embedded"; +} + +export function previewViteArgs(port: number | undefined): string[] { + return ["--host", "127.0.0.1", ...(port === undefined ? [] : ["--port", String(port)])]; +} + +// All preview modes bind the same IPv4 loopback so lifecycle probes and handed +// URLs agree on the reachable server. function previewBaseUrl(port: number, host = "127.0.0.1"): string { return `http://${host}:${port}`; } @@ -956,10 +973,14 @@ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise { HYPERFRAMES_AUTO_PROXY: "false", }); }); + + it("identifies a detached Vite preview to the lifecycle scanner", () => { + expect( + studioProxyEnv( + true, + { KEEP: "yes" }, + { + projectDir: "/tmp/video", + projectName: "video", + browserGpuMode: "software", + }, + ), + ).toMatchObject({ + HYPERFRAMES_PREVIEW_PROJECT_DIR: "/tmp/video", + HYPERFRAMES_PREVIEW_PROJECT_NAME: "video", + HYPERFRAMES_PREVIEW_BROWSER_GPU_MODE: "software", + }); + }); }); diff --git a/packages/cli/src/utils/studioProxyEnv.ts b/packages/cli/src/utils/studioProxyEnv.ts index cd19e4060b..2974f814bc 100644 --- a/packages/cli/src/utils/studioProxyEnv.ts +++ b/packages/cli/src/utils/studioProxyEnv.ts @@ -1,9 +1,23 @@ export function studioProxyEnv( autoProxy: boolean, baseEnv: NodeJS.ProcessEnv = process.env, + preview?: { + projectDir: string; + projectName: string; + browserGpuMode?: "auto" | "hardware" | "software"; + }, ): NodeJS.ProcessEnv { return { ...baseEnv, HYPERFRAMES_AUTO_PROXY: autoProxy ? "true" : "false", + ...(preview + ? { + HYPERFRAMES_PREVIEW_PROJECT_DIR: preview.projectDir, + HYPERFRAMES_PREVIEW_PROJECT_NAME: preview.projectName, + ...(preview.browserGpuMode + ? { HYPERFRAMES_PREVIEW_BROWSER_GPU_MODE: preview.browserGpuMode } + : {}), + } + : {}), }; } diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index 1485e4f022..ef217b46f8 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -158,6 +158,12 @@ "types": "./dist/audioAutomation.d.ts", "environments": ["browser", "bun", "node"] }, + "./audio-gain": { + "source": "./src/audioGain.ts", + "runtime": "./dist/audioGain.js", + "types": "./dist/audioGain.d.ts", + "environments": ["browser", "bun", "node"] + }, "./color-grading": { "source": "./src/colorGrading.ts", "runtime": "./dist/colorGrading.js", diff --git a/packages/core/package.json b/packages/core/package.json index 41077e31d1..c830528b9c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -172,6 +172,12 @@ "import": "./src/audioAutomation.ts", "types": "./src/audioAutomation.ts" }, + "./audio-gain": { + "bun": "./src/audioGain.ts", + "node": "./dist/audioGain.js", + "import": "./src/audioGain.ts", + "types": "./src/audioGain.ts" + }, "./color-grading": { "bun": "./src/colorGrading.ts", "node": "./dist/colorGrading.js", @@ -478,6 +484,10 @@ "import": "./dist/audioAutomation.js", "types": "./dist/audioAutomation.d.ts" }, + "./audio-gain": { + "import": "./dist/audioGain.js", + "types": "./dist/audioGain.d.ts" + }, "./color-grading": { "import": "./dist/colorGrading.js", "types": "./dist/colorGrading.d.ts" diff --git a/packages/core/src/audioGain.test.ts b/packages/core/src/audioGain.test.ts new file mode 100644 index 0000000000..394c046f1b --- /dev/null +++ b/packages/core/src/audioGain.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + AUDIO_GAIN_FADER_MAX, + AUDIO_GAIN_FADER_MIN, + MAX_AUDIO_GAIN, + audioGainToFaderPosition, + audioGainToText, + audioFaderPositionToGain, +} from "./audioGain"; + +describe("audio gain fader", () => { + it("puts unity gain at the physical midpoint", () => { + expect(audioGainToFaderPosition(1)).toBe(0); + expect(audioFaderPositionToGain(0)).toBe(1); + }); + + it("provides +12 dB of boost above unity", () => { + expect(audioFaderPositionToGain(AUDIO_GAIN_FADER_MAX)).toBeCloseTo(MAX_AUDIO_GAIN, 6); + expect(audioGainToText(MAX_AUDIO_GAIN)).toBe("+12.0 dB"); + }); + + it("preserves a true silence endpoint below unity", () => { + expect(audioFaderPositionToGain(AUDIO_GAIN_FADER_MIN)).toBe(0); + expect(audioGainToText(0)).toBe("-∞ dB"); + }); + + it("pins sub-floor gain to the fader's silence endpoint", () => { + expect(audioGainToFaderPosition(0.00001)).toBe(AUDIO_GAIN_FADER_MIN); + }); + + it("round-trips representative attenuation and boost values", () => { + for (const gain of [0.1, 0.5, 1, 2, MAX_AUDIO_GAIN]) { + expect(audioFaderPositionToGain(audioGainToFaderPosition(gain))).toBeCloseTo(gain, 6); + } + }); +}); diff --git a/packages/core/src/audioGain.ts b/packages/core/src/audioGain.ts new file mode 100644 index 0000000000..41a98cf185 --- /dev/null +++ b/packages/core/src/audioGain.ts @@ -0,0 +1,54 @@ +/** + * Authoring gain for a media clip. + * + * HTMLMediaElement.volume is limited to 0..1, but HyperFrames' Web Audio + * preview and FFmpeg render paths both support gain above unity. Keep the + * shared ceiling here so Studio, preview, and render cannot drift. + */ +export const MAX_AUDIO_GAIN_DB = 12; +export const MAX_AUDIO_GAIN = 10 ** (MAX_AUDIO_GAIN_DB / 20); + +/** Studio fader coordinates. Unity is deliberately the physical midpoint. */ +export const AUDIO_GAIN_FADER_MIN = -100; +export const AUDIO_GAIN_FADER_MAX = 100; + +const MIN_AUDIO_GAIN_DB = -60; + +export function clampAudioGain(value: number): number { + if (!Number.isFinite(value)) return 1; + return Math.max(0, Math.min(MAX_AUDIO_GAIN, value)); +} + +export function clampNativeMediaVolume(value: number): number { + if (!Number.isFinite(value)) return 1; + return Math.max(0, Math.min(1, value)); +} + +export function audioFaderPositionToGain(position: number): number { + const safe = Math.max(AUDIO_GAIN_FADER_MIN, Math.min(AUDIO_GAIN_FADER_MAX, position)); + if (safe === AUDIO_GAIN_FADER_MIN) return 0; + const db = + safe < 0 + ? (safe / Math.abs(AUDIO_GAIN_FADER_MIN)) * Math.abs(MIN_AUDIO_GAIN_DB) + : (safe / AUDIO_GAIN_FADER_MAX) * MAX_AUDIO_GAIN_DB; + return 10 ** (db / 20); +} + +export function audioGainToFaderPosition(gain: number): number { + const safe = clampAudioGain(gain); + if (safe === 0) return AUDIO_GAIN_FADER_MIN; + const db = 20 * Math.log10(safe); + const position = + db < 0 + ? (db / Math.abs(MIN_AUDIO_GAIN_DB)) * Math.abs(AUDIO_GAIN_FADER_MIN) + : (db / MAX_AUDIO_GAIN_DB) * AUDIO_GAIN_FADER_MAX; + return Math.max(AUDIO_GAIN_FADER_MIN, Math.min(AUDIO_GAIN_FADER_MAX, position)); +} + +export function audioGainToText(gain: number): string { + const safe = clampAudioGain(gain); + if (safe === 0) return "-∞ dB"; + const db = 20 * Math.log10(safe); + const rounded = Math.abs(db) < 0.05 ? 0 : db; + return (rounded > 0 ? "+" : "") + rounded.toFixed(1) + " dB"; +} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index cd8bd8b2ea..39f3e3c718 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -3146,7 +3146,7 @@ export function initSandboxRuntimeModular(): void { if (!(el instanceof HTMLMediaElement)) continue; const parsed = parseFloat(el.dataset.volume ?? ""); const clipVolume = Number.isFinite(parsed) ? parsed : 1; - el.volume = clipVolume * volume; + el.volume = Math.max(0, Math.min(1, clipVolume * volume)); } }, onSetMediaOutputMuted: (muted) => { diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index bf697161b8..fc182c32da 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -365,6 +365,25 @@ describe("syncRuntimeMedia", () => { expect(only).toBeCloseTo(0.55, 5); }); + it("sends boosted author gain to Web Audio while keeping the native element legal", () => { + const clip = createMockClip({ start: 0, end: 10, volume: 3.98 }); + Object.defineProperty(clip.el, "readyState", { value: 4, writable: true }); + let transportGain = -1; + + syncRuntimeMedia({ + clips: [clip], + timeSeconds: 1, + playing: true, + playbackRate: 1, + onElementVolume: (_el, volume) => { + transportGain = volume; + }, + }); + + expect(transportGain).toBeCloseTo(3.98, 5); + expect(clip.el.volume).toBe(1); + }); + /** * The render bakes the lane at CLIP-LOCAL time: prepareAudioTrack already * cut the wav with `-ss mediaStart`, so its t=0 is the clip's start, and diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 40ae5ec176..08d41ebf29 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -2,6 +2,7 @@ import { swallow } from "./diagnostics"; import { interpolateVolumeGain, type VolumeKeyframe } from "./mediaVolumeEnvelope.js"; import { elementVolumeLaneGain } from "./audioAutomationVolume.js"; import { normalizePlaybackRate } from "./playbackRate.js"; +import { clampAudioGain, clampNativeMediaVolume } from "../audioGain.js"; export function readElementPlaybackRate(el: Element): number { const authored = Number.parseFloat(el.getAttribute("data-playback-rate") ?? ""); @@ -162,11 +163,6 @@ function isUnplayable(el: HTMLMediaElement): boolean { const lastRuntimeAppliedVolume = new WeakMap(); -function clampVolume(volume: number): number { - if (!Number.isFinite(volume)) return 1; - return Math.max(0, Math.min(1, volume)); -} - /** * Drop every per-source sync baseline tracked for `el` — offset drift * samples, the seek-past-buffered-range retry latch, and the last @@ -265,10 +261,10 @@ export function syncRuntimeMedia(params: { relTime = clip.mediaStart + ((relTime - clip.mediaStart) % loopLength); } } - const userVol = clampVolume(params.userVolume ?? 1); - const fallbackAuthorVolume = clampVolume(clip.volume ?? 1); + const userVol = clampNativeMediaVolume(params.userVolume ?? 1); + const fallbackAuthorVolume = clampAudioGain(clip.volume ?? 1); const previousRuntimeVolume = lastRuntimeAppliedVolume.get(el); - const currentElementVolume = clampVolume(el.volume); + const currentElementVolume = clampNativeMediaVolume(el.volume); let authorVolume: number; // An explicit volume lane owns the fader. It is checked before the probed @@ -284,7 +280,7 @@ export function syncRuntimeMedia(params: { // there is one time base, and this is it. const laneGain = elementVolumeLaneGain(el, params.timeSeconds - clip.start); if (laneGain !== null) { - authorVolume = clampVolume(laneGain); + authorVolume = clampAudioGain(laneGain); } else if (clip.volumeKeyframes && clip.volumeKeyframes.length > 0) { // Keyframes probed from the GSAP timeline — same source as the renderer. // Use the interpolated envelope value directly; no need to track GSAP changes. @@ -294,13 +290,13 @@ export function syncRuntimeMedia(params: { // and the playback rate — so it only coincides with the envelope's time base // for an untrimmed clip playing at 1x from t=0. const elapsedInClip = params.timeSeconds - clip.start; - authorVolume = clampVolume(interpolateVolumeGain(clip.volumeKeyframes, elapsedInClip)); + authorVolume = clampAudioGain(interpolateVolumeGain(clip.volumeKeyframes, elapsedInClip)); } else if (previousRuntimeVolume === undefined) { // First tick this clip is active. The transport has already seeked GSAP // to the current time (seekTimelineAndAdapters runs before syncRuntimeMedia), // so el.volume reflects the animated value — trust it rather than falling // back to data-volume, which would clobber the GSAP-seeked position. - authorVolume = currentElementVolume; + authorVolume = fallbackAuthorVolume > 1 ? fallbackAuthorVolume : currentElementVolume; } else if (Math.abs(currentElementVolume - previousRuntimeVolume) > 0.0001) { // GSAP (or user code) changed el.volume between ticks — track it. authorVolume = currentElementVolume; @@ -309,10 +305,11 @@ export function syncRuntimeMedia(params: { authorVolume = fallbackAuthorVolume; } - const effectiveVolume = clampVolume(authorVolume * userVol); - el.volume = effectiveVolume; - lastRuntimeAppliedVolume.set(el, effectiveVolume); - params.onElementVolume?.(el, effectiveVolume); + const effectiveGain = clampAudioGain(authorVolume * userVol); + const nativeVolume = clampNativeMediaVolume(effectiveGain); + el.volume = nativeVolume; + lastRuntimeAppliedVolume.set(el, nativeVolume); + params.onElementVolume?.(el, effectiveGain); // Mute only when force-muted or the transport owns this element; an unclaimed // track stays audible via the HTMLMedia fallback. if (forceMuteAll || params.isWebAudioOwned?.(el)) el.muted = true; diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index 35f935bc5a..725393ff98 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -166,6 +166,15 @@ describe("WebAudioTransport", () => { }); describe("schedulePlayback timing", () => { + it("keeps author boost above unity on the per-element gain node", async () => { + const { transport, mock, gen } = setupTransport(100); + + await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, 0, 1, gen); + transport.setElementVolume(mockEl, 3.98); + + expect(mock.gainNode.gain.value).toBeCloseTo(3.98, 5); + }); + it("starts in-progress clips immediately with correct buffer offset", async () => { const { transport, mock, gen } = setupTransport(100); diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index e2e2dc66ed..92b812d0cf 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -7,6 +7,7 @@ import { import { VOLUME_RANGE } from "../audioAutomation.js"; import { swallow } from "./diagnostics"; import { getDebugSurface } from "./globals.js"; +import { clampAudioGain } from "../audioGain.js"; function normalizeRate(rate: number): number { if (!Number.isFinite(rate) || rate <= 0) return 1; @@ -206,7 +207,7 @@ export class WebAudioTransport { sourceNode.playbackRate.value = safeRate; const gainNode = this._ctx.createGain(); - gainNode.gain.value = volume; + gainNode.gain.value = clampAudioGain(volume); const elapsed = compositionTime - compositionStart; const scheduledAt = this._ctx.currentTime; @@ -353,7 +354,7 @@ export class WebAudioTransport { } setElementVolume(el: HTMLMediaElement, volume: number): void { - const safeVolume = Math.max(0, Math.min(1, volume)); + const safeVolume = clampAudioGain(volume); for (const source of this._activeSources) { if (source.el !== el) continue; try { diff --git a/packages/engine/src/services/audioMixer.test.ts b/packages/engine/src/services/audioMixer.test.ts index cac9b10ae8..891658b723 100644 --- a/packages/engine/src/services/audioMixer.test.ts +++ b/packages/engine/src/services/audioMixer.test.ts @@ -257,6 +257,35 @@ describe("processCompositionAudio", () => { expect(filter).not.toContain("weights="); }); + it("preserves authored clip gain above unity for quiet-source boosting", async () => { + const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); + tempDirs.push(baseDir, workDir); + writeFileSync(join(baseDir, "quiet.wav"), "stub"); + + const result = await processCompositionAudio( + [ + { + id: "quiet", + src: "quiet.wav", + start: 0, + end: 2, + mediaStart: 0, + layer: 0, + volume: 3.98, + type: "audio", + }, + ], + baseDir, + workDir, + join(baseDir, "out.m4a"), + 2, + ); + + expect(result.success).toBe(true); + expect(capturedFilterScripts[1]).toContain("volume=3.98"); + }); + it("lets an FX tail run past the clip, still bounded by the composition", async () => { // A reverb is still decaying when the clip's own audio stops. Trimming at // the clip boundary is what cut every tail short in the render. diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index e71d26587b..d3f146c560 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -41,6 +41,7 @@ import { import { chainTailSeconds } from "@hyperframes/core/audio-fx-tail"; import { applyAudioFxChain, AudioFxRenderError } from "./audioFxRender.js"; import type { AudioVolumeKeyframe } from "./audioMixer.types.js"; +import { clampAudioGain } from "@hyperframes/core/audio-gain"; export type { AudioElement, MixResult } from "./audioMixer.types.js"; @@ -60,8 +61,7 @@ export type { AudioElement, MixResult } from "./audioMixer.types.js"; export const MIXED_AUDIO_FILENAME = "audio.m4a"; function clampVolume(volume: number): number { - if (!Number.isFinite(volume)) return 1; - return Math.max(0, Math.min(1, volume)); + return clampAudioGain(volume); } function formatFilterNumber(value: number): string { diff --git a/packages/parsers/src/types.ts b/packages/parsers/src/types.ts index 4820ba34fd..f402726c2b 100644 --- a/packages/parsers/src/types.ts +++ b/packages/parsers/src/types.ts @@ -160,7 +160,7 @@ export interface TimelineMediaElement extends TimelineElementBase { isAroll?: boolean; sourceWidth?: number; sourceHeight?: number; - volume?: number; // 0-1 (0% to 100%), default 1.0 + volume?: number; // linear gain; 0 is silent, 1 is 0 dB, values above 1 boost hasAudio?: boolean; // For videos - indicates if video has audio track } diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx index 125f5a1830..e7ef34099f 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx @@ -131,9 +131,9 @@ describe("FlatMediaSection — cutout", () => { }); describe("FlatMediaSection — volume/rate/media-start", () => { - it("renders volume at its stored percentage and commits a new value on drag", () => { + it("renders unity volume as neutral 0 dB at the slider midpoint", () => { const onSetAttribute = vi.fn(); - const element = makeVideoElement({ dataAttributes: { volume: "0.5" } }); + const element = makeVideoElement({ dataAttributes: { volume: "1" } }); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); @@ -149,13 +149,16 @@ describe("FlatMediaSection — volume/rate/media-start", () => { />, ); }); - expect(host.textContent).toContain("50%"); + expect(host.textContent).toContain("0.0 dB"); + expect( + host.querySelector('[data-flat-slider-track="true"]')?.getAttribute("aria-valuenow"), + ).toBe("0"); act(() => root.unmount()); }); - it("commits a new volume value on slider track pointerdown", () => { + it("commits +12 dB of boost from the upper half of the volume fader", () => { const onSetAttribute = vi.fn(); - const element = makeVideoElement({ dataAttributes: { volume: "0.2" } }); + const element = makeVideoElement({ dataAttributes: { volume: "1" } }); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); @@ -176,11 +179,10 @@ describe("FlatMediaSection — volume/rate/media-start", () => { value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), }); act(() => { - volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 })); - volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 })); + volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 })); + volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 })); }); - // starting volume 0.2 (draft=20); min=0, max=100, ratio=0.5 -> raw=50 -> commit(50) -> 50/100=0.5 -> "0.5" - expect(onSetAttribute).toHaveBeenCalledWith("volume", "0.5"); + expect(onSetAttribute).toHaveBeenCalledWith("volume", "3.98"); act(() => root.unmount()); }); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx index 997483e136..e2fbf547d7 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -13,6 +13,13 @@ import { import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives"; import { FlatToggle } from "./propertyPanelFlatToggle"; import { AutomationToggle } from "./propertyPanelFxControls"; +import { + AUDIO_GAIN_FADER_MAX, + AUDIO_GAIN_FADER_MIN, + audioFaderPositionToGain, + audioGainToFaderPosition, + audioGainToText, +} from "@hyperframes/core/audio-gain"; // fallow-ignore-next-line complexity export function FlatMediaSection({ @@ -54,7 +61,7 @@ export function FlatMediaSection({ const el = element.element; const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1; - const volumePercent = Math.round(volume * 100); + const volumeFaderPosition = audioGainToFaderPosition(volume); const mediaStart = Number.parseFloat( element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0", @@ -215,13 +222,16 @@ export function FlatMediaSection({
void onSetAttribute("volume", formatNumericValue(next / 100))} + centerTick + onCommit={(next) => + void onSetAttribute("volume", formatNumericValue(audioFaderPositionToGain(next))) + } />
Volume `${Math.round(next)}%`} + displayValue={audioGainToText(volume)} + formatDisplayValue={(next) => audioGainToText(audioFaderPositionToGain(next))} onCommit={(next) => { - void onSetAttribute("volume", formatNumericValue(next / 100)); + void onSetAttribute("volume", formatNumericValue(audioFaderPositionToGain(next))); }} /> diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index 97830c5d65..da670ed85f 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -5,6 +5,7 @@ import { join, resolve } from "node:path"; import { readNodeRequestBody } from "./vite.request-body.js"; import { watch } from "chokidar"; import { createViteAdapter } from "./vite.adapter"; +import { previewConfigPayload } from "./vite.preview-config"; async function loadRuntimeSourceForDev( server: import("vite").ViteDevServer, @@ -84,6 +85,14 @@ function devProjectApi(): Plugin { return _api; }; + server.middlewares.use((req, res, next) => { + if (req.url !== "/__hyperframes_config") return next(); + const payload = previewConfigPayload(process.env, process.pid, studioPkg.version); + if (!payload) return next(); + res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }); + res.end(JSON.stringify(payload)); + }); + // Runtime endpoint — prefer source build over dist artifact server.middlewares.use((req, res, next) => { if (req.url !== "/api/runtime.js") return next(); diff --git a/packages/studio/vite.preview-config.test.ts b/packages/studio/vite.preview-config.test.ts new file mode 100644 index 0000000000..865de6cbf1 --- /dev/null +++ b/packages/studio/vite.preview-config.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { previewConfigPayload } from "./vite.preview-config"; + +describe("previewConfigPayload", () => { + it("identifies a detached Vite preview to the CLI lifecycle scanner", () => { + expect( + previewConfigPayload( + { + HYPERFRAMES_PREVIEW_PROJECT_DIR: "/tmp/video", + HYPERFRAMES_PREVIEW_PROJECT_NAME: "video", + HYPERFRAMES_PREVIEW_BROWSER_GPU_MODE: "software", + }, + 4321, + "0.7.109", + ), + ).toEqual({ + isHyperframes: true, + pid: 4321, + projectName: "video", + projectDir: "/tmp/video", + serverBuildSignature: null, + browserGpuMode: "software", + version: "0.7.109", + }); + }); + + it("does not claim unrelated direct Vite sessions", () => { + expect(previewConfigPayload({})).toBeNull(); + }); +}); diff --git a/packages/studio/vite.preview-config.ts b/packages/studio/vite.preview-config.ts new file mode 100644 index 0000000000..3fe0c38d1d --- /dev/null +++ b/packages/studio/vite.preview-config.ts @@ -0,0 +1,22 @@ +export type PreviewConfigEnv = Record; + +export function previewConfigPayload( + env: PreviewConfigEnv, + pid = process.pid, + version = "dev", +): Record | null { + const projectDir = env.HYPERFRAMES_PREVIEW_PROJECT_DIR; + const projectName = env.HYPERFRAMES_PREVIEW_PROJECT_NAME; + if (!projectDir || !projectName) return null; + + const browserGpuMode = env.HYPERFRAMES_PREVIEW_BROWSER_GPU_MODE; + return { + isHyperframes: true, + pid, + projectName, + projectDir, + serverBuildSignature: null, + ...(browserGpuMode ? { browserGpuMode } : {}), + version, + }; +} From 8758b4c59fcdbf1097017b90da4d37a98355a7f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 14 Aug 2026 23:10:24 +0000 Subject: [PATCH 02/16] docs(skills): require persistent preview handoffs --- skills-manifest.json | 14 ++++++------- skills/faceless-explainer/SKILL.md | 2 +- .../hyperframes-audio/references/diagnosis.md | 21 +++++++++++++++++++ skills/hyperframes-cli/SKILL.md | 4 ++-- .../references/preview-render.md | 5 +++-- skills/hyperframes-core/SKILL.md | 2 +- .../references/review-loop.md | 2 +- skills/motion-graphics/SKILL.md | 2 +- skills/product-launch-video/SKILL.md | 2 +- skills/talking-head-recut/SKILL.md | 2 +- 10 files changed, 39 insertions(+), 17 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index f12b4ae853..905362ea28 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,7 +6,7 @@ "files": 138 }, "faceless-explainer": { - "hash": "c70b904aa68cf7e5", + "hash": "e5142b87a79e62dd", "files": 24 }, "figma": { @@ -26,15 +26,15 @@ "files": 121 }, "hyperframes-audio": { - "hash": "534cea75fe0f2bc6", + "hash": "bcd72fa055559886", "files": 6 }, "hyperframes-cli": { - "hash": "e042fcaaa3f9767f", + "hash": "3405b9b5fa3cad54", "files": 11 }, "hyperframes-core": { - "hash": "e9daaccaed05b8b4", + "hash": "62968dbb5b6930cb", "files": 19 }, "hyperframes-creative": { @@ -54,7 +54,7 @@ "files": 152 }, "motion-graphics": { - "hash": "1434e22bb0259bbb", + "hash": "69dc088b8e0d22fe", "files": 23 }, "music-to-video": { @@ -66,7 +66,7 @@ "files": 30 }, "product-launch-video": { - "hash": "81953f054fcb9d91", + "hash": "11368bf71a32ba49", "files": 28 }, "remotion-to-hyperframes": { @@ -78,7 +78,7 @@ "files": 2 }, "talking-head-recut": { - "hash": "2f5d99f823c48e75", + "hash": "214eda4c0f2bedb1", "files": 28 } } diff --git a/skills/faceless-explainer/SKILL.md b/skills/faceless-explainer/SKILL.md index 3a519b3f48..3ba1891a9c 100644 --- a/skills/faceless-explainer/SKILL.md +++ b/skills/faceless-explainer/SKILL.md @@ -191,7 +191,7 @@ If a command fails, surface stderr and stop — don't pile on recovery commands. After checks pass, pause for user review — the review loop's final look (`../hyperframes-core/references/review-loop.md` § 4): one question, on the Studio that has been open since Step 3 — render now, or what changes? (Autonomous: the one kept question, preview first or render.) Then deliver the MP4 with the contact sheet and the frame ids so revisions can target a single frame. -Preview: `npx hyperframes preview` +Preview: `npx hyperframes preview --background` Render only after user approval (autonomous mode: after the preview-or-render question): diff --git a/skills/hyperframes-audio/references/diagnosis.md b/skills/hyperframes-audio/references/diagnosis.md index e92e0166fd..47fb3786df 100644 --- a/skills/hyperframes-audio/references/diagnosis.md +++ b/skills/hyperframes-audio/references/diagnosis.md @@ -135,6 +135,27 @@ the ambiguity instead. ## Recipes +### Compare loudness from the bytes the listener actually hears + +Do not call two clips equally loud because their Studio faders, waveform peaks, +or cached asset metadata match. Those are controls and proxies, not a loudness +measurement. Resolve the exact URLs used by preview/render, download or inspect +those exact served bytes, and measure each decoded stream with FFmpeg's +`ebur128` filter. Compare the integrated LUFS values. + +For a target loudness, the required move is: + +```text +gain_db = target_lufs - measured_lufs +linear_gain = 10 ** (gain_db / 20) +``` + +Studio's clip-gain fader uses `0 dB` / linear gain `1` at its physical midpoint +and provides up to `+12 dB` on the upper half. After changing gain, measure the +served preview/render bytes again. If a listener still hears a mismatch, trust +the report and first verify the asset URL and bytes are current; do not explain +it away with matching peaks or a stale proxy measurement. + All verified with ffmpeg 8.1.1. `-hide_banner` keeps the output readable; `volumedetect` prints to stderr, so do not silence it with `-v error`. diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index b01ee482c1..1ead76589f 100644 --- a/skills/hyperframes-cli/SKILL.md +++ b/skills/hyperframes-cli/SKILL.md @@ -21,7 +21,7 @@ Run commands as `npx hyperframes ...` unless project instructions provide a wrap 4. **Get fast feedback while editing:** run `npx hyperframes lint` after the first HTML pass and after structural changes. 5. **Run the final gate:** run `npx hyperframes check`; it reruns lint before opening the browser. Do not prepend a redundant standalone lint invocation. Add `--snapshots` for annotated overview frames and finding crops. 6. **Inspect sub-compositions:** when `index.html` mounts `data-composition-src`, capture midpoint snapshots and inspect each mounted scene. -7. **Open the final Studio preview:** run `npx hyperframes preview`, hand the timeline project URL to the user, and ask whether to revise or render. +7. **Open the final Studio preview:** run `npx hyperframes preview --background`, verify the URL returns HTTP 200, hand the timeline project URL to the user, and ask whether to revise or render. Keep it alive until review ends. 8. **Render only after approval:** use draft quality for iteration and high quality for delivery. 9. **Verify the output:** confirm the file exists, is non-empty, and has a plausible duration. @@ -31,7 +31,7 @@ npx hyperframes lint # Required final gate; includes lint. npx hyperframes check -npx hyperframes preview +npx hyperframes preview --background npx hyperframes render --quality high --output out.mp4 test -s out.mp4 ffprobe -v error -show_format out.mp4 diff --git a/skills/hyperframes-cli/references/preview-render.md b/skills/hyperframes-cli/references/preview-render.md index f5ed1f4e8e..5a54ae416b 100644 --- a/skills/hyperframes-cli/references/preview-render.md +++ b/skills/hyperframes-cli/references/preview-render.md @@ -5,7 +5,8 @@ Serve, render, and share commands. ## preview ```bash -npx hyperframes preview # serve current directory +npx hyperframes preview --background # agent-safe; survives the invoking command +npx hyperframes preview # interactive foreground session 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 @@ -23,7 +24,7 @@ Use the actual port and project directory name; treat `index.html` as source-cod To land the user on the **Storyboard view** instead of the timeline, put `?view=storyboard` ahead of the hash: `http://localhost:/?view=storyboard#project/`. Hand this URL whenever the storyboard is the thing to review and nothing is assembled yet — before `index.html` exists, the timeline stage has nothing to show, so the bare project URL opens on an empty player. -Two ways a handed URL turns out dead — check both before handing it back: the URL is missing its `#project/` hash (Studio loads but has no project to open), or the server is not actually running. `preview` is a long-running process — start it from the project directory as a background task, and if that task reports it exited ("completed"), the server is down: restart it, don't hand out the link. +Two ways a handed URL turns out dead — check both before handing it back: the URL is missing its `#project/` hash (Studio loads but has no project to open), or the server is not actually running. Agents must start it with `preview --background`: a foreground server is tied to the invoking tool session, and its exit strands the handed URL at `ERR_CONNECTION_TIMED_OUT`. Verify the URL returns HTTP 200, keep it alive for the whole review, and stop it explicitly with `npx hyperframes preview --stop` afterward. ### Agent context from Studio selection diff --git a/skills/hyperframes-core/SKILL.md b/skills/hyperframes-core/SKILL.md index 3e44a4e54a..1ee9f83549 100644 --- a/skills/hyperframes-core/SKILL.md +++ b/skills/hyperframes-core/SKILL.md @@ -85,5 +85,5 @@ Use `hyperframes-cli` for command details - [ ] `npx hyperframes check` passes (0 findings across lint, runtime, layout, motion, and contrast) - [ ] Projects with sub-compositions: `npx hyperframes snapshot --at ` and eyeball each frame -- [ ] `npx hyperframes preview` for review (the user can edit anything in Studio's timeline) +- [ ] `npx hyperframes preview --background` for review (the user can edit anything in Studio's timeline, and the server survives the invoking command) - [ ] `npx hyperframes render` only after the user approves diff --git a/skills/hyperframes-core/references/review-loop.md b/skills/hyperframes-core/references/review-loop.md index 7ae9d5e6df..8a9ecc3d67 100644 --- a/skills/hyperframes-core/references/review-loop.md +++ b/skills/hyperframes-core/references/review-loop.md @@ -6,7 +6,7 @@ This is the shared process for any workflow that plans on a storyboard. The cont ## § 1 — The plan, on a live board -Open the **storyboard board** before presenting the plan: run `npx hyperframes preview` from the project directory in the background, confirm it is serving, and open `http://localhost:/?view=storyboard#project/`. This is an early planning surface, not the final composition preview; it may open before composition checks. The plan appears as frame cards and refreshes as work lands. +Open the **storyboard board** before presenting the plan: run `npx hyperframes preview --background` from the project directory, confirm it is serving, and open `http://localhost:/?view=storyboard#project/`. This is an early planning surface, not the final composition preview; it may open before composition checks. The plan appears as frame cards and refreshes as work lands. Present the plan as a proposal (shape: `hyperframes-creative/references/story-spine.md` § 3): open by echoing **"This video tells [audience] that [message]"**, then the frame table — one row per frame: frame · beat (type, duration) · on screen · why (its `narrativeRole`, traced to the message). Hand the board URL with it, noting feedback lands in both places — comment on the board or reply here, one revision loop — and that a board submit still needs one reply here (anything) to get picked up. diff --git a/skills/motion-graphics/SKILL.md b/skills/motion-graphics/SKILL.md index 90bf727088..e0302dbee9 100644 --- a/skills/motion-graphics/SKILL.md +++ b/skills/motion-graphics/SKILL.md @@ -136,7 +136,7 @@ Choose proof times that show the opening state, signature move, and final hold. Ask one question: “preview first, or render?” If the user chooses preview, open Studio and return to the same approval gate after revisions: ```bash -(cd "$PROJECT_DIR" && npx hyperframes preview) +(cd "$PROJECT_DIR" && npx hyperframes preview --background) ``` Render only after an explicit render answer: diff --git a/skills/product-launch-video/SKILL.md b/skills/product-launch-video/SKILL.md index 1fd786de99..464604cfdd 100644 --- a/skills/product-launch-video/SKILL.md +++ b/skills/product-launch-video/SKILL.md @@ -220,7 +220,7 @@ If a command fails, surface stderr and stop — don't pile on recovery commands. After checks pass, pause for user review — the review loop's final look (`../hyperframes-core/references/review-loop.md` § 4): one question, on the Studio that has been open since Step 3 — render now, or what changes? (Autonomous: the one kept question, preview first or render.) Then deliver the MP4 with the contact sheet and the frame ids so revisions can target a single frame. -Preview: `npx hyperframes preview` +Preview: `npx hyperframes preview --background` Render only after user approval (autonomous mode: after the preview-or-render question): diff --git a/skills/talking-head-recut/SKILL.md b/skills/talking-head-recut/SKILL.md index a9c797ff52..275c7bf129 100644 --- a/skills/talking-head-recut/SKILL.md +++ b/skills/talking-head-recut/SKILL.md @@ -1204,7 +1204,7 @@ Tell the user: **Optional live preview (on request only).** The clip plays unchanged inside `public/index.html` with the overlays on top, so it previews faithfully. **Don't open it during the run.** When the user asks, start a long-lived server **after** render and report the URL: ```bash -(cd "$WORK_DIR/public" && npx hyperframes preview) # or `npx hyperframes play` for a shareable link +(cd "$WORK_DIR/public" && npx hyperframes preview --background) # or `npx hyperframes play` for a shareable link ``` Do not delete the work directory unless the user asks. From ebb4a0aee2ba663e84526c4326463aea3f99b6f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 14 Aug 2026 23:45:15 +0000 Subject: [PATCH 03/16] fix(cli): reap stopped background previews --- packages/cli/src/commands/preview.test.ts | 51 ++++++++++++++++++- packages/cli/src/commands/preview.ts | 40 +++++++++++---- .../cli/src/commands/previewLifecycle.test.ts | 20 ++++++++ packages/cli/src/commands/previewLifecycle.ts | 13 ++++- 4 files changed, 110 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts index 154d1039ea..e8674d868d 100644 --- a/packages/cli/src/commands/preview.test.ts +++ b/packages/cli/src/commands/preview.test.ts @@ -1,8 +1,13 @@ 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 { previewLaunchMode, previewViteArgs, studioLandingSearch } from "./preview.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + previewLaunchMode, + previewViteArgs, + studioLandingSearch, + waitForStudioChildClose, +} from "./preview.js"; const tempDirs: string[] = []; @@ -79,3 +84,45 @@ describe("previewLaunchMode", () => { expect(previewViteArgs(3032)).toEqual(["--host", "127.0.0.1", "--port", "3032"]); }); }); + +describe("waitForStudioChildClose", () => { + it("resolves when the child closed before the listener was attached", async () => { + const signalTarget = { once: vi.fn(), off: vi.fn() }; + const child = { + exitCode: 1, + signalCode: null, + once: vi.fn(), + } as unknown as Parameters[0]; + + await expect(waitForStudioChildClose(child, signalTarget)).resolves.toBeUndefined(); + expect(child.once).not.toHaveBeenCalled(); + expect(signalTarget.once).toHaveBeenCalledTimes(2); + expect(signalTarget.off).toHaveBeenCalledTimes(2); + }); + + it("reaps on process exit even when stdio never emits close", async () => { + let exit: (() => void) | undefined; + const signalTarget = { once: vi.fn(), off: vi.fn() }; + const child = { + exitCode: null, + signalCode: null, + once: vi.fn((event: string, listener: () => void) => { + if (event === "exit") exit = listener; + }), + } as unknown as Parameters[0]; + + let resolved = false; + const waiting = waitForStudioChildClose(child, signalTarget).then(() => { + resolved = true; + }); + + await Promise.resolve(); + expect(resolved).toBe(false); + expect(child.once).toHaveBeenCalledWith("exit", expect.any(Function)); + + exit?.(); + await waiting; + expect(resolved).toBe(true); + expect(signalTarget.off).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 02d495f9dc..ec2883daa0 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -92,6 +92,10 @@ interface EmbeddedStudioOptions extends StudioLaunchOptions { } type StudioChildProcess = ChildProcessByStdio; +interface StudioSignalTarget { + once(event: "SIGINT" | "SIGTERM", listener: () => void): unknown; + off(event: "SIGINT" | "SIGTERM", listener: () => void): unknown; +} type ContextField = "server" | "selection" | "lint" | "capabilities"; type CompactSelectionPayload = Pick< StudioSelectionSnapshot, @@ -910,17 +914,33 @@ function removeSymlinkOnExit(createdSymlink: boolean, symlinkPath: string): void }); } -function registerChildTreeShutdown(child: StudioChildProcess): void { +export function waitForStudioChildClose( + child: StudioChildProcess, + signalTarget: StudioSignalTarget = process, +): Promise { const shutdown = (): void => { if (child.pid) killProcessTree(child.pid); }; - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); -} + signalTarget.once("SIGINT", shutdown); + signalTarget.once("SIGTERM", shutdown); + + // A short-lived Vite child can exit before launch setup reaches this point. + // ChildProcess does not replay lifecycle events to listeners attached later, + // so waiting unconditionally would strand the preview wrapper forever. + const closed = + child.exitCode !== null || child.signalCode !== null + ? Promise.resolve() + : new Promise((resolveClose) => { + // `close` waits for stdio to close too. A Vite descendant can inherit + // those pipes, so the wrapper must key its lifetime to process exit. + child.once("exit", () => resolveClose()); + }); -function waitForChildClose(child: StudioChildProcess): Promise { - return new Promise((resolveClose) => { - child.on("close", () => resolveClose()); + return closed.finally(() => { + // Signal listeners keep Bun's event loop alive even after Vite exits. Leaving + // them registered makes `preview --stop` close the port but leak the wrapper. + signalTarget.off("SIGINT", shutdown); + signalTarget.off("SIGTERM", shutdown); }); } @@ -992,8 +1012,7 @@ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise { expect(kill).toHaveBeenCalledWith(4321); }); + it("stops the detached wrapper as well as its reported Vite server", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + savePreviewSession(stateHome); + let running = true; + const scan = vi.fn(async () => (running ? [{ ...server, pid: "9876" }] : [])); + const kill = vi.fn((pid: number) => { + if (pid === 9876) running = false; + }); + + const result = await stopBackgroundPreview(projectDir, 3002, { + scan, + kill, + sleep: async () => {}, + stateHome, + }); + + expect(result).toBe(true); + expect(kill.mock.calls).toEqual([[9876], [4321]]); + }); + it("fails loudly when the server remains reachable after stop", async () => { const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); writePreviewSession( diff --git a/packages/cli/src/commands/previewLifecycle.ts b/packages/cli/src/commands/previewLifecycle.ts index eaec7f91ab..20d26eab32 100644 --- a/packages/cli/src/commands/previewLifecycle.ts +++ b/packages/cli/src/commands/previewLifecycle.ts @@ -268,7 +268,18 @@ export async function stopBackgroundPreview( return false; } - (dependencies.kill ?? stopProcess)(pid); + const kill = dependencies.kill ?? stopProcess; + kill(pid); + + // Dev/local mode exposes Vite's PID, while the session records the detached + // CLI wrapper. Both must be reaped: killing only Vite closes the port but can + // leave the wrapper waiting on inherited stdio forever. The live matching + // server above is the ownership proof that makes the saved PID safe to use. + const wrapperPid = Number(saved?.pid); + if (Number.isInteger(wrapperPid) && wrapperPid > 0 && wrapperPid !== pid) { + kill(wrapperPid); + } + const sleep = dependencies.sleep ?? delay; for (let attempt = 0; attempt < 25; attempt++) { if (!matchingServer(await scan(scanStart), projectDir)) { From 61df3086cf1af3f1a41e583c947c8ebdd5ae24f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 15 Aug 2026 01:29:23 +0000 Subject: [PATCH 04/16] docs(skills): keep agent Studio previews alive --- skills-manifest.json | 4 ++-- skills/hyperframes-cli/references/preview-render.md | 8 ++++---- skills/slideshow/SKILL.md | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 905362ea28..db18367224 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -30,7 +30,7 @@ "files": 6 }, "hyperframes-cli": { - "hash": "3405b9b5fa3cad54", + "hash": "8145aac05f931cc4", "files": 11 }, "hyperframes-core": { @@ -74,7 +74,7 @@ "files": 70 }, "slideshow": { - "hash": "6a24a84b0c1a75f9", + "hash": "2029471821f6f371", "files": 2 }, "talking-head-recut": { diff --git a/skills/hyperframes-cli/references/preview-render.md b/skills/hyperframes-cli/references/preview-render.md index 5a54ae416b..289d2f6322 100644 --- a/skills/hyperframes-cli/references/preview-render.md +++ b/skills/hyperframes-cli/references/preview-render.md @@ -7,7 +7,7 @@ Serve, render, and share commands. ```bash npx hyperframes preview --background # agent-safe; survives the invoking command npx hyperframes preview # interactive foreground session -npx hyperframes preview --port 4567 # custom port (default 3002) +npx hyperframes preview --background --port 4567 # agent-safe 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 ``` @@ -20,7 +20,7 @@ When handing a project back to the user, use the Studio project URL, not the sou http://localhost:/#project/ ``` -Use the actual port and project directory name; treat `index.html` as source-code context, not the preview surface. For example, after `npx hyperframes preview --port 3017` in `codex-openai-video`, report `http://localhost:3017/#project/codex-openai-video`. +Use the actual port and project directory name; treat `index.html` as source-code context, not the preview surface. For example, after `npx hyperframes preview --background --port 3017` in `codex-openai-video`, report `http://localhost:3017/#project/codex-openai-video`. To land the user on the **Storyboard view** instead of the timeline, put `?view=storyboard` ahead of the hash: `http://localhost:/?view=storyboard#project/`. Hand this URL whenever the storyboard is the thing to review and nothing is assembled yet — before `index.html` exists, the timeline stage has nothing to show, so the bare project URL opens on an empty player. @@ -58,7 +58,7 @@ Failure modes: | Code | Meaning | | -------------------------- | -------------------------------------------------------------------------- | -| `preview-not-running` | Start Studio first with `npx hyperframes preview`. | +| `preview-not-running` | Start Studio first with `npx hyperframes preview --background`. | | `ambiguous-preview-server` | Multiple matching Studio servers are open; rerun with one listed `--port`. | | `preview-port-mismatch` | The requested `--port` is not one of the matching Studio servers. | | `no-selection` | Studio is open, but the user has not selected an element yet. | @@ -90,7 +90,7 @@ Both `preview` and `play` can open inside an explicit Chromium-compatible browse ```bash # Open preview in an isolated Chromium profile -npx hyperframes preview --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile +npx hyperframes preview --background --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile # Same plus a CDP endpoint on :9222 (attach DevTools / Playwright / etc.) npx hyperframes play --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile --remote-debugging-port 9222 diff --git a/skills/slideshow/SKILL.md b/skills/slideshow/SKILL.md index fffa3f99b2..57df159ff6 100644 --- a/skills/slideshow/SKILL.md +++ b/skills/slideshow/SKILL.md @@ -494,7 +494,7 @@ Studio/`preview` is useful for editing a composition, but it is not a clear fina { "scripts": { "dev": "npx hyperframes present ./composition", - "studio": "npx hyperframes preview ./composition" + "studio": "npx hyperframes preview ./composition --background" } } ``` From f0c238d987020dfe4d8cbeb92713fafa362077e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 15 Aug 2026 02:03:08 +0000 Subject: [PATCH 05/16] docs(cli): persist previews in project instructions --- packages/cli/src/commands/coreSkillContent.test.ts | 11 +++++++++++ packages/cli/src/templates/_shared/AGENTS.md | 13 +++++++++---- packages/cli/src/templates/_shared/CLAUDE.md | 13 +++++++++---- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/coreSkillContent.test.ts b/packages/cli/src/commands/coreSkillContent.test.ts index fe25f5764d..635579de30 100644 --- a/packages/cli/src/commands/coreSkillContent.test.ts +++ b/packages/cli/src/commands/coreSkillContent.test.ts @@ -125,4 +125,15 @@ describe("media treatment routing documentation", () => { expect(template).toContain("do not improvise equivalent CSS/SVG filters or overlays"); } }); + + it("gives agents a process-owned preview lifecycle in new project instructions", () => { + for (const file of ["AGENTS.md", "CLAUDE.md"]) { + const template = read("packages", "cli", "src", "templates", "_shared", file); + expect(template).toContain("npx hyperframes preview --background"); + expect(template).toContain("npx hyperframes preview --status"); + expect(template).toContain("npx hyperframes preview --stop"); + expect(template).toContain("leaving refreshes at `ERR_CONNECTION_TIMED_OUT`"); + expect(template).not.toContain("run_in_background: true"); + } + }); }); diff --git a/packages/cli/src/templates/_shared/AGENTS.md b/packages/cli/src/templates/_shared/AGENTS.md index 1762ea7a66..31ae625c33 100644 --- a/packages/cli/src/templates/_shared/AGENTS.md +++ b/packages/cli/src/templates/_shared/AGENTS.md @@ -33,7 +33,10 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes- ## Commands ```bash -npm run dev # start the preview server (long-running — keep it alive in background) +npm run dev # human-operated foreground preview (blocks until stopped) +npx hyperframes preview --background # agent-safe persistent Studio preview +npx hyperframes preview --status # verify the persistent preview is listening +npx hyperframes preview --stop # stop it when review is finished npm run check # lint + runtime + layout + motion + contrast (one command) npm run render # render to MP4 npm run publish # publish and get a shareable link @@ -42,9 +45,11 @@ npx hyperframes lint --json # machine-readable output for CI npx hyperframes docs # reference docs in terminal ``` -> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped. -> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground -> command — it will time out and the server will die, breaking the browser preview. +> **Agents must use `npx hyperframes preview --background` for Studio handoff.** Do not rely +> on a shell/tool `run_in_background` wrapper around `npm run dev`: that foreground process +> remains owned by the invoking session and can disappear while the browser stays open, +> leaving refreshes at `ERR_CONNECTION_TIMED_OUT`. Verify with `preview --status`, keep it +> alive through review, and stop it explicitly with `preview --stop` afterward. > **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project . --check` (shows the delta), then `npx hyperframes@latest upgrade --project .` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself. diff --git a/packages/cli/src/templates/_shared/CLAUDE.md b/packages/cli/src/templates/_shared/CLAUDE.md index 1762ea7a66..31ae625c33 100644 --- a/packages/cli/src/templates/_shared/CLAUDE.md +++ b/packages/cli/src/templates/_shared/CLAUDE.md @@ -33,7 +33,10 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes- ## Commands ```bash -npm run dev # start the preview server (long-running — keep it alive in background) +npm run dev # human-operated foreground preview (blocks until stopped) +npx hyperframes preview --background # agent-safe persistent Studio preview +npx hyperframes preview --status # verify the persistent preview is listening +npx hyperframes preview --stop # stop it when review is finished npm run check # lint + runtime + layout + motion + contrast (one command) npm run render # render to MP4 npm run publish # publish and get a shareable link @@ -42,9 +45,11 @@ npx hyperframes lint --json # machine-readable output for CI npx hyperframes docs # reference docs in terminal ``` -> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped. -> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground -> command — it will time out and the server will die, breaking the browser preview. +> **Agents must use `npx hyperframes preview --background` for Studio handoff.** Do not rely +> on a shell/tool `run_in_background` wrapper around `npm run dev`: that foreground process +> remains owned by the invoking session and can disappear while the browser stays open, +> leaving refreshes at `ERR_CONNECTION_TIMED_OUT`. Verify with `preview --status`, keep it +> alive through review, and stop it explicitly with `preview --stop` afterward. > **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project . --check` (shows the delta), then `npx hyperframes@latest upgrade --project .` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself. From f5a29078957b7b413deb5b1e159591ba5b9be4f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 15 Aug 2026 02:16:03 +0000 Subject: [PATCH 06/16] docs(cli): design agent-safe preview lifecycle --- ...26-08-15-agent-preview-lifecycle-design.md | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-15-agent-preview-lifecycle-design.md diff --git a/docs/superpowers/specs/2026-08-15-agent-preview-lifecycle-design.md b/docs/superpowers/specs/2026-08-15-agent-preview-lifecycle-design.md new file mode 100644 index 0000000000..6bb0deda78 --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-agent-preview-lifecycle-design.md @@ -0,0 +1,155 @@ +# Agent-safe preview lifecycle + +## Context + +HyperFrames Studio is commonly launched by an agent through `npx hyperframes preview`. A foreground child process remains owned by the invoking shell or tool session, so the preview listener can disappear when that session is reclaimed even though the user never presses Ctrl+C or closes the browser. The next browser reload then reports `ERR_CONNECTION_TIMED_OUT`. + +PR #3280 already adds an explicit persistent lifecycle (`--background`, `--status`, `--stop`, `--list`, and `--kill-all`). The remaining problem is discoverability and default behavior: an agent that invokes the bare command can still choose the fragile lifecycle accidentally, and the command prints the server root rather than the exact Studio project route. + +## Goals + +- Make the safe persistent lifecycle the default for non-interactive and agent invocations. +- Preserve the familiar foreground process, logs, and Ctrl+C behavior for a human terminal. +- Give agents an exact project URL and structured lifecycle state without parsing prose. +- Keep lifecycle operations idempotent and ownership-safe. +- Maintain explicit overrides for scripts whose desired lifecycle differs from the detected environment. + +## Non-goals + +- Changing Studio rendering, project discovery, or media serving. +- Keeping arbitrary foreground processes alive after their owning shell exits. +- Managing preview processes that HyperFrames cannot prove it owns. +- Treating an HTTP-ready Studio server as proof that every composition rendered successfully. + +## Launch-mode contract + +The CLI resolves launch mode in this order while preserving the existing explicit-mode precedence: + +1. `--background` selects the managed persistent lifecycle. +2. Existing explicit development and locally installed Studio modes retain their current priority and behavior. +3. `--foreground` disables automatic persistence and selects the foreground lifecycle appropriate to the project. +4. Otherwise, an interactive TTY selects the existing foreground lifecycle appropriate to the project. +5. Otherwise, a non-interactive invocation selects managed persistent mode. + +`--background` and `--foreground` are mutually exclusive and fail before starting a process when supplied together. + +TTY detection is an input to a small pure launch-mode resolver. Production passes `Boolean(process.stdin.isTTY && process.stdout.isTTY)`. Tests inject both states directly; they do not depend on the test runner's terminal. `--foreground` is a lifecycle override rather than a new server implementation: after it suppresses automatic backgrounding, the existing dev/local/embedded resolver still chooses the foreground server. + +This preserves human behavior while making the agent path safe without requiring every skill or prompt to remember a flag. + +## Human output + +Every successful foreground start, background start, or background reuse prints the exact Studio deep link produced by the existing `studioDeepLink()` policy. The root server URL may remain as secondary diagnostic information, but it is not the primary handoff URL. + +Background output also names: + +- whether the session was started or reused; +- project directory and project name; +- PID and loopback port; +- log path; +- `preview --status` and `preview --stop` follow-up commands. + +`preview --status` prints the same deep link and lifecycle fields. It reports no session distinctly from a stale session that was found and cleaned. + +## Machine-readable output + +`--json` is extended from selection/context queries to lifecycle commands. JSON mode writes one JSON document to stdout and sends incidental notices to stderr, preserving the CLI's existing machine-output convention. + +The lifecycle envelope is versioned: + +```ts +type PreviewLifecycleResult = { + schemaVersion: 1; + operation: "start" | "status" | "stop" | "list" | "kill-all"; + ok: boolean; + result: + | { + state: "started" | "reused" | "running" | "stopped"; + mode: "background" | "foreground"; + projectName: string; + projectDir: string; + host: "127.0.0.1"; + port: number; + pid: number | null; + serverUrl: string; + studioUrl: string; + ready: boolean; + logPath?: string; + } + | { state: "not-running"; projectDir?: string } + | { state: "listed"; sessions: PreviewSessionSummary[] } + | { state: "killed-all"; stopped: number }; +}; +``` + +Failure JSON uses the existing CLI error-envelope convention and stable error codes. A foreground start only emits its successful JSON result after the listener becomes reachable; the command then remains attached until terminated. + +## Session ownership and reuse + +The existing persistent-session record remains the source of truth. A session is reusable only when all of the following hold: + +- the record belongs to the same canonical project directory; +- the recorded process is alive; +- the HyperFrames server scan finds a reachable listener that identifies the same canonical project directory; +- the live server PID, or the recorded wrapper PID when the server omits one, is valid. + +An unhealthy or stale record is cleaned before a new managed process starts. `--force-new` bypasses reuse without weakening ownership checks. `--stop` and `--kill-all` only signal processes after a reachable HyperFrames listener proves the canonical project identity; a record alone is never authority to kill a PID. Ambiguous processes are reported rather than killed. + +The primary Studio URL is derived at response time from the session port and current project state, so an old record never freezes a stale route choice. + +## Readiness semantics + +`ready: true` means the owned preview server is reachable on the expected IPv4 loopback endpoint and can serve the Studio shell. It does not claim composition-level success. Composition errors remain visible through Studio, lint, context, and browser diagnostics. + +Managed start retains the bounded readiness wait. If readiness times out, the CLI stops and reaps the process it just created, removes its record, reports the log path, and exits non-zero. It never leaves an untracked child behind. + +## Skill and generated-project guidance + +Skills continue to recommend `npx hyperframes preview --background` for an explicit handoff, because explicit intent remains useful and works with older CLI versions. Generated project guidance also documents `--status` and `--stop`. + +The new non-interactive default is a safety net, not a reason to remove explicit guidance. Tests pin the source skill, mirrored skill artifacts, and generated `AGENTS.md` / `CLAUDE.md` templates. + +## Verification + +Unit and integration coverage must include: + +- TTY selects foreground; non-TTY selects background; +- explicit `--background` and `--foreground` override detection; +- conflicting flags fail without spawning; +- identical project invocation reuses a healthy managed session; +- `--force-new` creates a distinct managed session; +- start, reuse, status, list, stop, and kill-all JSON schemas; +- stdout remains valid JSON in JSON mode; +- human start and status output use the exact Studio deep link; +- stale record cleanup and ownership-mismatch refusal; +- readiness timeout reaps the new process and removes its record; +- stop reaps both the preview process and any recorded wrapper; +- generated and mirrored agent instructions remain consistent. + +End-to-end verification uses the packed CLI through the real `npx hyperframes preview` entry point: + +1. Human/PTY foreground start stays attached and stops on Ctrl+C. +2. Non-TTY bare start returns after creating a managed preview. +3. The printed Studio deep link loads the intended project. +4. The preview survives launcher exit and repeated hard browser reloads. +5. `--status --json` returns the live identity and route. +6. `--stop` removes the listener and session record. + +An independent agent-acceptance pass follows the automated suite. Subagents receive a clean project and only the installed HyperFrames skill plus public CLI help; they are not told the implementation or the expected internal process model. They must independently: + +1. discover how to launch and hand off Studio; +2. obtain the exact project deep link; +3. re-run the launch and observe safe session reuse; +4. confirm repeated hard reloads survive after the launching tool call returns; +5. inspect lifecycle state in both human and JSON forms; +6. stop the preview and confirm the listener and recorded identity are gone. + +Failures in discovery, ambiguous output, accidental duplicate servers, or leaked processes are product defects even when lower-level tests pass. + +The user-provided project used to reproduce the timeout remains private and is not committed as a fixture. A synthetic project covers the same lifecycle contract in automated tests. + +## Compatibility and rollout + +Interactive users see no default lifecycle change. Non-interactive scripts that relied on the bare command blocking must add `--foreground`; this is an intentional correction because the old implicit lifecycle was unsafe for the primary agent use case. The release note and CLI help call out the override. + +The change ships in PR #3280 with the existing audio and persistent-preview fixes so the reported Studio failures are addressed and verified together. CI and review feedback are monitored to green; merge remains a human decision. From 0c517862cc7a7b8947204bcc0dde0002b6871e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 15 Aug 2026 02:18:57 +0000 Subject: [PATCH 07/16] chore: keep preview planning out of the PR --- ...26-08-15-agent-preview-lifecycle-design.md | 155 ------------------ 1 file changed, 155 deletions(-) delete mode 100644 docs/superpowers/specs/2026-08-15-agent-preview-lifecycle-design.md diff --git a/docs/superpowers/specs/2026-08-15-agent-preview-lifecycle-design.md b/docs/superpowers/specs/2026-08-15-agent-preview-lifecycle-design.md deleted file mode 100644 index 6bb0deda78..0000000000 --- a/docs/superpowers/specs/2026-08-15-agent-preview-lifecycle-design.md +++ /dev/null @@ -1,155 +0,0 @@ -# Agent-safe preview lifecycle - -## Context - -HyperFrames Studio is commonly launched by an agent through `npx hyperframes preview`. A foreground child process remains owned by the invoking shell or tool session, so the preview listener can disappear when that session is reclaimed even though the user never presses Ctrl+C or closes the browser. The next browser reload then reports `ERR_CONNECTION_TIMED_OUT`. - -PR #3280 already adds an explicit persistent lifecycle (`--background`, `--status`, `--stop`, `--list`, and `--kill-all`). The remaining problem is discoverability and default behavior: an agent that invokes the bare command can still choose the fragile lifecycle accidentally, and the command prints the server root rather than the exact Studio project route. - -## Goals - -- Make the safe persistent lifecycle the default for non-interactive and agent invocations. -- Preserve the familiar foreground process, logs, and Ctrl+C behavior for a human terminal. -- Give agents an exact project URL and structured lifecycle state without parsing prose. -- Keep lifecycle operations idempotent and ownership-safe. -- Maintain explicit overrides for scripts whose desired lifecycle differs from the detected environment. - -## Non-goals - -- Changing Studio rendering, project discovery, or media serving. -- Keeping arbitrary foreground processes alive after their owning shell exits. -- Managing preview processes that HyperFrames cannot prove it owns. -- Treating an HTTP-ready Studio server as proof that every composition rendered successfully. - -## Launch-mode contract - -The CLI resolves launch mode in this order while preserving the existing explicit-mode precedence: - -1. `--background` selects the managed persistent lifecycle. -2. Existing explicit development and locally installed Studio modes retain their current priority and behavior. -3. `--foreground` disables automatic persistence and selects the foreground lifecycle appropriate to the project. -4. Otherwise, an interactive TTY selects the existing foreground lifecycle appropriate to the project. -5. Otherwise, a non-interactive invocation selects managed persistent mode. - -`--background` and `--foreground` are mutually exclusive and fail before starting a process when supplied together. - -TTY detection is an input to a small pure launch-mode resolver. Production passes `Boolean(process.stdin.isTTY && process.stdout.isTTY)`. Tests inject both states directly; they do not depend on the test runner's terminal. `--foreground` is a lifecycle override rather than a new server implementation: after it suppresses automatic backgrounding, the existing dev/local/embedded resolver still chooses the foreground server. - -This preserves human behavior while making the agent path safe without requiring every skill or prompt to remember a flag. - -## Human output - -Every successful foreground start, background start, or background reuse prints the exact Studio deep link produced by the existing `studioDeepLink()` policy. The root server URL may remain as secondary diagnostic information, but it is not the primary handoff URL. - -Background output also names: - -- whether the session was started or reused; -- project directory and project name; -- PID and loopback port; -- log path; -- `preview --status` and `preview --stop` follow-up commands. - -`preview --status` prints the same deep link and lifecycle fields. It reports no session distinctly from a stale session that was found and cleaned. - -## Machine-readable output - -`--json` is extended from selection/context queries to lifecycle commands. JSON mode writes one JSON document to stdout and sends incidental notices to stderr, preserving the CLI's existing machine-output convention. - -The lifecycle envelope is versioned: - -```ts -type PreviewLifecycleResult = { - schemaVersion: 1; - operation: "start" | "status" | "stop" | "list" | "kill-all"; - ok: boolean; - result: - | { - state: "started" | "reused" | "running" | "stopped"; - mode: "background" | "foreground"; - projectName: string; - projectDir: string; - host: "127.0.0.1"; - port: number; - pid: number | null; - serverUrl: string; - studioUrl: string; - ready: boolean; - logPath?: string; - } - | { state: "not-running"; projectDir?: string } - | { state: "listed"; sessions: PreviewSessionSummary[] } - | { state: "killed-all"; stopped: number }; -}; -``` - -Failure JSON uses the existing CLI error-envelope convention and stable error codes. A foreground start only emits its successful JSON result after the listener becomes reachable; the command then remains attached until terminated. - -## Session ownership and reuse - -The existing persistent-session record remains the source of truth. A session is reusable only when all of the following hold: - -- the record belongs to the same canonical project directory; -- the recorded process is alive; -- the HyperFrames server scan finds a reachable listener that identifies the same canonical project directory; -- the live server PID, or the recorded wrapper PID when the server omits one, is valid. - -An unhealthy or stale record is cleaned before a new managed process starts. `--force-new` bypasses reuse without weakening ownership checks. `--stop` and `--kill-all` only signal processes after a reachable HyperFrames listener proves the canonical project identity; a record alone is never authority to kill a PID. Ambiguous processes are reported rather than killed. - -The primary Studio URL is derived at response time from the session port and current project state, so an old record never freezes a stale route choice. - -## Readiness semantics - -`ready: true` means the owned preview server is reachable on the expected IPv4 loopback endpoint and can serve the Studio shell. It does not claim composition-level success. Composition errors remain visible through Studio, lint, context, and browser diagnostics. - -Managed start retains the bounded readiness wait. If readiness times out, the CLI stops and reaps the process it just created, removes its record, reports the log path, and exits non-zero. It never leaves an untracked child behind. - -## Skill and generated-project guidance - -Skills continue to recommend `npx hyperframes preview --background` for an explicit handoff, because explicit intent remains useful and works with older CLI versions. Generated project guidance also documents `--status` and `--stop`. - -The new non-interactive default is a safety net, not a reason to remove explicit guidance. Tests pin the source skill, mirrored skill artifacts, and generated `AGENTS.md` / `CLAUDE.md` templates. - -## Verification - -Unit and integration coverage must include: - -- TTY selects foreground; non-TTY selects background; -- explicit `--background` and `--foreground` override detection; -- conflicting flags fail without spawning; -- identical project invocation reuses a healthy managed session; -- `--force-new` creates a distinct managed session; -- start, reuse, status, list, stop, and kill-all JSON schemas; -- stdout remains valid JSON in JSON mode; -- human start and status output use the exact Studio deep link; -- stale record cleanup and ownership-mismatch refusal; -- readiness timeout reaps the new process and removes its record; -- stop reaps both the preview process and any recorded wrapper; -- generated and mirrored agent instructions remain consistent. - -End-to-end verification uses the packed CLI through the real `npx hyperframes preview` entry point: - -1. Human/PTY foreground start stays attached and stops on Ctrl+C. -2. Non-TTY bare start returns after creating a managed preview. -3. The printed Studio deep link loads the intended project. -4. The preview survives launcher exit and repeated hard browser reloads. -5. `--status --json` returns the live identity and route. -6. `--stop` removes the listener and session record. - -An independent agent-acceptance pass follows the automated suite. Subagents receive a clean project and only the installed HyperFrames skill plus public CLI help; they are not told the implementation or the expected internal process model. They must independently: - -1. discover how to launch and hand off Studio; -2. obtain the exact project deep link; -3. re-run the launch and observe safe session reuse; -4. confirm repeated hard reloads survive after the launching tool call returns; -5. inspect lifecycle state in both human and JSON forms; -6. stop the preview and confirm the listener and recorded identity are gone. - -Failures in discovery, ambiguous output, accidental duplicate servers, or leaked processes are product defects even when lower-level tests pass. - -The user-provided project used to reproduce the timeout remains private and is not committed as a fixture. A synthetic project covers the same lifecycle contract in automated tests. - -## Compatibility and rollout - -Interactive users see no default lifecycle change. Non-interactive scripts that relied on the bare command blocking must add `--foreground`; this is an intentional correction because the old implicit lifecycle was unsafe for the primary agent use case. The release note and CLI help call out the override. - -The change ships in PR #3280 with the existing audio and persistent-preview fixes so the reported Studio failures are addressed and verified together. CI and review feedback are monitored to green; merge remains a human decision. From a655cc7664060be027dbdee001db1e91301d04ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 15 Aug 2026 02:27:33 +0000 Subject: [PATCH 08/16] feat(cli): default agent previews to persistent sessions --- packages/cli/src/commands/preview.test.ts | 72 ++++++++++++++++++----- packages/cli/src/commands/preview.ts | 30 ++++++++++ 2 files changed, 87 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts index e8674d868d..ae1c7fef50 100644 --- a/packages/cli/src/commands/preview.test.ts +++ b/packages/cli/src/commands/preview.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { previewLaunchMode, + previewLaunchModeError, previewViteArgs, studioLandingSearch, waitForStudioChildClose, @@ -58,26 +59,67 @@ describe("studioLandingSearch", () => { }); describe("previewLaunchMode", () => { - it("keeps background preview persistent in monorepo dev mode", () => { - expect(previewLaunchMode({ background: true, devMode: true, localStudio: false })).toBe( + it.each([ + [ + { + background: false, + foreground: false, + interactive: false, + devMode: false, + localStudio: false, + }, "background", - ); - }); - - it("keeps background preview persistent with a project-local Studio", () => { - expect(previewLaunchMode({ background: true, devMode: false, localStudio: true })).toBe( + ], + [ + { + background: false, + foreground: false, + interactive: true, + devMode: false, + localStudio: false, + }, + "embedded", + ], + [ + { + background: false, + foreground: true, + interactive: false, + devMode: true, + localStudio: false, + }, + "dev", + ], + [ + { + background: false, + foreground: true, + interactive: false, + devMode: false, + localStudio: true, + }, + "local", + ], + [ + { + background: true, + foreground: false, + interactive: true, + devMode: true, + localStudio: true, + }, "background", - ); + ], + ] as const)("resolves %o to %s", (options, expected) => { + expect(previewLaunchMode(options)).toBe(expected); }); - it("preserves the foreground launch preference", () => { - expect(previewLaunchMode({ background: false, devMode: true, localStudio: true })).toBe("dev"); - expect(previewLaunchMode({ background: false, devMode: false, localStudio: true })).toBe( - "local", - ); - expect(previewLaunchMode({ background: false, devMode: false, localStudio: false })).toBe( - "embedded", + it("rejects conflicting lifecycle overrides", () => { + expect(previewLaunchModeError({ background: true, foreground: true })).toBe( + "--background and --foreground cannot be used together", ); + expect(previewLaunchModeError({ background: true, foreground: false })).toBeNull(); + expect(previewLaunchModeError({ background: false, foreground: true })).toBeNull(); }); it("pins detached Vite to the port the lifecycle scanner waits on", () => { diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index ec2883daa0..48f8f2b1d7 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -13,6 +13,7 @@ export const examples: Example[] = [ ["Use a custom port", "hyperframes preview --port 8080"], ["Force a new server even if one is already running", "hyperframes preview --force-new"], ["Keep preview running after this command exits", "hyperframes preview --background"], + ["Force an attached preview in a non-interactive shell", "hyperframes preview --foreground"], ["Show the background preview for this project", "hyperframes preview --status"], ["Stop the background preview for this project", "hyperframes preview --stop"], ["Start without opening the browser", "hyperframes preview --no-open"], @@ -129,6 +130,11 @@ export default defineCommand({ description: "Start a preview that remains running after the command exits", default: false, }, + foreground: { + type: "boolean", + description: "Keep preview attached even when the shell is non-interactive", + default: false, + }, status: { type: "boolean", description: "Show the background preview for this project and exit", @@ -212,6 +218,16 @@ export default defineCommand({ }, }, async run({ args }) { + const launchModeError = previewLaunchModeError({ + background: Boolean(args.background), + foreground: Boolean(args.foreground), + }); + if (launchModeError) { + clack.log.error(launchModeError); + setCommandExitCode(1); + return; + } + const browserGpuMode = resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined); if (args["browser-gpu"] === true) process.env.PRODUCER_BROWSER_GPU_MODE = "hardware"; if (args["browser-gpu"] === false) process.env.PRODUCER_BROWSER_GPU_MODE = "software"; @@ -359,6 +375,8 @@ export default defineCommand({ const launchMode = previewLaunchMode({ background: Boolean(args.background), + foreground: Boolean(args.foreground), + interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), devMode: isDevMode(), localStudio: hasLocalStudio(dir), }); @@ -444,14 +462,26 @@ export type PreviewLaunchMode = "background" | "dev" | "local" | "embedded"; export function previewLaunchMode(options: { background: boolean; + foreground: boolean; + interactive: boolean; devMode: boolean; localStudio: boolean; }): PreviewLaunchMode { if (options.background) return "background"; + if (!options.foreground && !options.interactive) return "background"; if (options.devMode) return "dev"; return options.localStudio ? "local" : "embedded"; } +export function previewLaunchModeError(options: { + background: boolean; + foreground: boolean; +}): string | null { + return options.background && options.foreground + ? "--background and --foreground cannot be used together" + : null; +} + export function previewViteArgs(port: number | undefined): string[] { return ["--host", "127.0.0.1", ...(port === undefined ? [] : ["--port", String(port)])]; } From 2d62706b8a2ae5425255bff1c016f7de8d50128c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 15 Aug 2026 02:29:07 +0000 Subject: [PATCH 09/16] fix(cli): hand agents the exact Studio project URL --- packages/cli/src/commands/preview.test.ts | 22 ++++++++++++++ packages/cli/src/commands/preview.ts | 36 +++++++++++++++-------- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts index ae1c7fef50..f13329151a 100644 --- a/packages/cli/src/commands/preview.test.ts +++ b/packages/cli/src/commands/preview.test.ts @@ -6,7 +6,9 @@ import { previewLaunchMode, previewLaunchModeError, previewViteArgs, + studioDeepLink, studioLandingSearch, + studioSummaryUrls, waitForStudioChildClose, } from "./preview.js"; @@ -58,6 +60,26 @@ describe("studioLandingSearch", () => { }); }); +describe("Studio handoff URLs", () => { + it("hands off the exact timeline project route", () => { + const dir = projectWith(null); + expect(studioDeepLink("http://127.0.0.1:3002", "demo", dir)).toBe( + "http://127.0.0.1:3002/#project/demo", + ); + expect(studioSummaryUrls("demo", "http://127.0.0.1:3002", dir)).toEqual({ + serverUrl: "http://127.0.0.1:3002", + studioUrl: "http://127.0.0.1:3002/#project/demo", + }); + }); + + it("hands off the exact storyboard route while a project is still planning", () => { + const dir = projectWith(FRAME(1, "outline")); + expect(studioDeepLink("http://127.0.0.1:3002", "demo", dir)).toBe( + "http://127.0.0.1:3002/?view=storyboard#project/demo", + ); + }); +}); + describe("previewLaunchMode", () => { it.each([ [ diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 48f8f2b1d7..fee38662bd 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -250,11 +250,9 @@ export default defineCommand({ console.log(`\n ${c.dim("No background preview is running for")} ${project.dir}\n`); return; } - console.log(`\n ${c.success("Background preview running")}`); - console.log( - ` ${c.accent(`http://localhost:${status.port}`)} ${c.dim(`(PID ${status.pid})`)}`, - ); - console.log(` ${c.dim(status.logPath)}\n`); + printStudioSummary(project.name, previewBaseUrl(status.port), project.dir, { + details: [`Background preview running (PID ${status.pid}).`, `Log: ${status.logPath}`], + }); return; } @@ -395,7 +393,7 @@ export default defineCommand({ } const url = `http://localhost:${background.port}`; clack.intro(c.bold("hyperframes preview")); - printStudioSummary(projectName, url, { + printStudioSummary(projectName, url, dir, { details: [ background.type === "reused" ? "Reusing the background server already running for this project." @@ -866,10 +864,21 @@ export function studioLandingSearch(projectDir: string): string { // The full Studio URL to open or hand to the user: status-aware landing view // plus the project hash route. `url` never carries a trailing slash (both the // embedded server and the Vite `Local:` match strip it). -function studioDeepLink(url: string, projectName: string, projectDir: string): string { +export function studioDeepLink(url: string, projectName: string, projectDir: string): string { return `${url}/${studioLandingSearch(projectDir)}#project/${projectName}`; } +export function studioSummaryUrls( + projectName: string, + serverUrl: string, + projectDir: string, +): { serverUrl: string; studioUrl: string } { + return { + serverUrl, + studioUrl: studioDeepLink(serverUrl, projectName, projectDir), + }; +} + function openStudioBrowser( url: string, projectName: string, @@ -887,12 +896,15 @@ function openStudioBrowser( function printStudioSummary( projectName: string, - url: string, + serverUrl: string, + projectDir: string, opts: { details?: string[]; footer?: string } = {}, ): void { + const urls = studioSummaryUrls(projectName, serverUrl, projectDir); console.log(); console.log(` ${c.dim("Project")} ${c.accent(projectName)}`); - console.log(` ${c.dim("Studio")} ${c.accent(url)}`); + console.log(` ${c.dim("Studio")} ${c.accent(urls.studioUrl)}`); + console.log(` ${c.dim("Server")} ${c.accent(urls.serverUrl)}`); console.log(); for (const detail of opts.details ?? []) { console.log(` ${c.dim(detail)}`); @@ -989,7 +1001,7 @@ function attachStudioReadyHandler( detected = true; spinner.stop(c.success("Studio running")); - printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" }); + printStudioSummary(projectName, url, projectDir, { footer: "Press Ctrl+C to stop" }); openStudioBrowser(url, projectName, projectDir, options); child.stdout.removeListener("data", handleOutput); child.stderr.removeListener("data", handleOutput); @@ -1160,7 +1172,7 @@ async function runEmbeddedMode( if (result.type === "already-running") { const url = `http://localhost:${result.port}`; s.stop(c.success("Already running")); - printStudioSummary(pName, url, { + printStudioSummary(pName, url, dir, { details: ["Reusing existing server. Use --force-new to start a fresh instance."], }); openStudioBrowser(url, pName, dir, options); @@ -1174,7 +1186,7 @@ async function runEmbeddedMode( console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`); console.log(); } - printStudioSummary(pName, url, { + printStudioSummary(pName, url, dir, { details: [ "Edit with your AI agent — it has HyperFrames skills installed.", "Changes reload automatically in the studio.", From fc8bc65509b2d7856d2d9a611d3e6a3750a3ee2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 15 Aug 2026 02:33:15 +0000 Subject: [PATCH 10/16] feat(cli): expose structured preview lifecycle state --- packages/cli/src/commands/preview.ts | 180 +++++++++++++++--- .../commands/previewLifecycleOutput.test.ts | 83 ++++++++ .../src/commands/previewLifecycleOutput.ts | 59 ++++++ 3 files changed, 299 insertions(+), 23 deletions(-) create mode 100644 packages/cli/src/commands/previewLifecycleOutput.test.ts create mode 100644 packages/cli/src/commands/previewLifecycleOutput.ts diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index fee38662bd..518bda7396 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -70,6 +70,12 @@ import { startBackgroundPreview, stopBackgroundPreview, } from "./previewLifecycle.js"; +import { + lifecycleFailurePayload, + lifecyclePayload, + writeLifecycleJson, + type PreviewLifecycleSession, +} from "./previewLifecycleOutput.js"; import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js"; interface BrowserLaunchOptions { @@ -167,7 +173,7 @@ export default defineCommand({ }, json: { type: "boolean", - description: "Output preview selection/context as JSON (only with --selection or --context)", + description: "Output selection, context, or managed lifecycle state as JSON", default: false, }, context: { @@ -223,7 +229,13 @@ export default defineCommand({ foreground: Boolean(args.foreground), }); if (launchModeError) { - clack.log.error(launchModeError); + if (args.json) { + writeLifecycleJson( + lifecycleFailurePayload("start", "conflicting-lifecycle-flags", launchModeError), + ); + } else { + clack.log.error(launchModeError); + } setCommandExitCode(1); return; } @@ -238,16 +250,50 @@ export default defineCommand({ const project = resolveProject(args.dir); if (args.stop) { const stopped = await stopBackgroundPreview(project.dir, startPort); - console.log( - stopped - ? `\n ${c.success("Stopped background preview")} ${c.dim(project.dir)}\n` - : `\n ${c.dim("No background preview is running for")} ${project.dir}\n`, - ); + if (args.json) { + writeLifecycleJson( + lifecyclePayload( + "stop", + stopped + ? { state: "stopped", projectDir: project.dir } + : { state: "not-running", projectDir: project.dir }, + ), + ); + } else { + console.log( + stopped + ? `\n ${c.success("Stopped background preview")} ${c.dim(project.dir)}\n` + : `\n ${c.dim("No background preview is running for")} ${project.dir}\n`, + ); + } return; } const status = await readBackgroundPreviewStatus(project.dir, startPort); if (!status) { - console.log(`\n ${c.dim("No background preview is running for")} ${project.dir}\n`); + if (args.json) { + writeLifecycleJson( + lifecyclePayload("status", { state: "not-running", projectDir: project.dir }), + ); + } else { + console.log(`\n ${c.dim("No background preview is running for")} ${project.dir}\n`); + } + return; + } + if (args.json) { + writeLifecycleJson( + lifecyclePayload( + "status", + previewLifecycleSession({ + state: "running", + mode: "background", + projectName: project.name, + projectDir: project.dir, + port: status.port, + pid: status.pid, + logPath: status.logPath, + }), + ), + ); return; } printStudioSummary(project.name, previewBaseUrl(status.port), project.dir, { @@ -259,6 +305,25 @@ export default defineCommand({ // --list: scan and display active servers if (args.list) { const servers = await scanActiveServers(startPort); + if (args.json) { + writeLifecycleJson( + lifecyclePayload("list", { + state: "listed", + sessions: servers.map((server) => + previewLifecycleSession({ + state: "running", + mode: "unknown", + projectName: server.projectName, + projectDir: server.projectDir, + port: server.port, + pid: server.pid ? Number(server.pid) : null, + host: server.host, + }), + ), + }), + ); + return; + } if (servers.length === 0) { console.log("\n No active preview servers found.\n"); return; @@ -278,11 +343,19 @@ export default defineCommand({ if (args["kill-all"]) { const servers = await scanActiveServers(startPort); if (servers.length === 0) { - console.log("\n No active preview servers to kill.\n"); + if (args.json) { + writeLifecycleJson(lifecyclePayload("kill-all", { state: "killed-all", stopped: 0 })); + } else { + console.log("\n No active preview servers to kill.\n"); + } return; } const killed = await killActiveServers(startPort); - console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.\n`); + if (args.json) { + writeLifecycleJson(lifecyclePayload("kill-all", { state: "killed-all", stopped: killed })); + } else { + console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.\n`); + } return; } @@ -308,7 +381,7 @@ export default defineCommand({ // Kill orphaned chrome-headless-shell processes from previous crashed sessions. const orphansKilled = killOrphanedProcesses(); - if (orphansKilled > 0) { + if (orphansKilled > 0 && !args.json) { console.log( ` ${c.dim(`Cleaned up ${orphansKilled} orphaned process${orphansKilled === 1 ? "" : "es"} from a previous session.`)}`, ); @@ -322,7 +395,7 @@ export default defineCommand({ // Lint before starting — surface issues for the agent to fix. const lintResult = await lintProject(dir); - if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) { + if (!args.json && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) { console.log(); for (const line of formatLintFindings(lintResult)) console.log(line); console.log(); @@ -379,6 +452,18 @@ export default defineCommand({ localStudio: hasLocalStudio(dir), }); + if (args.json && launchMode !== "background") { + writeLifecycleJson( + lifecycleFailurePayload( + "start", + "foreground-json-unsupported", + "--json requires a managed preview; remove --foreground or use --background", + ), + ); + setCommandExitCode(1); + return; + } + if (launchMode === "background") { let background; try { @@ -387,21 +472,43 @@ export default defineCommand({ browserGpuMode, }); } catch (error) { - clack.log.error(errorMessage(error)); + const message = errorMessage(error); + if (args.json) { + writeLifecycleJson(lifecycleFailurePayload("start", "preview-start-failed", message)); + } else { + clack.log.error(message); + } setCommandExitCode(1); return; } const url = `http://localhost:${background.port}`; - clack.intro(c.bold("hyperframes preview")); - printStudioSummary(projectName, url, dir, { - details: [ - background.type === "reused" - ? "Reusing the background server already running for this project." - : `Running in the background. Log: ${background.logPath}`, - "Changes reload automatically in the studio.", - ], - footer: `Stop with: hyperframes preview ${JSON.stringify(dir)} --stop`, - }); + if (args.json) { + writeLifecycleJson( + lifecyclePayload( + "start", + previewLifecycleSession({ + state: background.type, + mode: "background", + projectName, + projectDir: dir, + port: background.port, + pid: background.pid, + ...(background.logPath ? { logPath: background.logPath } : {}), + }), + ), + ); + } else { + clack.intro(c.bold("hyperframes preview")); + printStudioSummary(projectName, url, dir, { + details: [ + background.type === "reused" + ? "Reusing the background server already running for this project." + : `Running in the background. Log: ${background.logPath}`, + "Changes reload automatically in the studio.", + ], + footer: `Stop with: hyperframes preview ${JSON.stringify(dir)} --stop`, + }); + } openStudioBrowser(url, projectName, dir, { noOpen, browserPath, @@ -879,6 +986,33 @@ export function studioSummaryUrls( }; } +function previewLifecycleSession(options: { + state: PreviewLifecycleSession["state"]; + mode: PreviewLifecycleSession["mode"]; + projectName: string; + projectDir: string; + port: number; + pid: number | null; + host?: string; + logPath?: string; +}): PreviewLifecycleSession { + const host = options.host ?? "127.0.0.1"; + const serverUrl = previewBaseUrl(options.port, host); + return { + state: options.state, + mode: options.mode, + projectName: options.projectName, + projectDir: options.projectDir, + host, + port: options.port, + pid: options.pid, + serverUrl, + studioUrl: studioDeepLink(serverUrl, options.projectName, options.projectDir), + ready: true, + ...(options.logPath ? { logPath: options.logPath } : {}), + }; +} + function openStudioBrowser( url: string, projectName: string, diff --git a/packages/cli/src/commands/previewLifecycleOutput.test.ts b/packages/cli/src/commands/previewLifecycleOutput.test.ts new file mode 100644 index 0000000000..780b4e876c --- /dev/null +++ b/packages/cli/src/commands/previewLifecycleOutput.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + lifecycleFailurePayload, + lifecyclePayload, + writeLifecycleJson, + type PreviewLifecycleSession, +} from "./previewLifecycleOutput.js"; + +const session: PreviewLifecycleSession = { + state: "started", + mode: "background", + projectName: "demo", + projectDir: "/tmp/demo", + host: "127.0.0.1", + port: 3002, + pid: 42, + serverUrl: "http://127.0.0.1:3002", + studioUrl: "http://127.0.0.1:3002/#project/demo", + ready: true, + logPath: "/tmp/demo.log", +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("preview lifecycle JSON", () => { + it("versions a managed start result", () => { + expect(lifecyclePayload("start", session)).toEqual({ + schemaVersion: 1, + operation: "start", + ok: true, + result: session, + }); + }); + + it.each([ + ["status", { state: "not-running", projectDir: "/tmp/demo" }], + ["stop", { state: "stopped", projectDir: "/tmp/demo" }], + ["list", { state: "listed", sessions: [session] }], + ["kill-all", { state: "killed-all", stopped: 1 }], + ] as const)("versions the %s result", (operation, result) => { + expect(lifecyclePayload(operation, result)).toMatchObject({ + schemaVersion: 1, + operation, + ok: true, + result, + }); + }); + + it("writes exactly one parseable JSON document", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + writeLifecycleJson(lifecyclePayload("start", { ...session, pid: null, state: "reused" })); + + expect(log).toHaveBeenCalledOnce(); + const [line] = log.mock.calls[0] as [string]; + expect(JSON.parse(line)).toEqual({ + schemaVersion: 1, + operation: "start", + ok: true, + result: { ...session, pid: null, state: "reused" }, + }); + }); + + it("versions lifecycle failures with a stable code", () => { + expect( + lifecycleFailurePayload( + "start", + "conflicting-lifecycle-flags", + "--background and --foreground cannot be used together", + ), + ).toEqual({ + schemaVersion: 1, + operation: "start", + ok: false, + error: { + code: "conflicting-lifecycle-flags", + message: "--background and --foreground cannot be used together", + }, + }); + }); +}); diff --git a/packages/cli/src/commands/previewLifecycleOutput.ts b/packages/cli/src/commands/previewLifecycleOutput.ts new file mode 100644 index 0000000000..7f538f55e7 --- /dev/null +++ b/packages/cli/src/commands/previewLifecycleOutput.ts @@ -0,0 +1,59 @@ +export type PreviewLifecycleOperation = "start" | "status" | "stop" | "list" | "kill-all"; + +export type PreviewLifecycleState = "started" | "reused" | "running"; + +export interface PreviewLifecycleSession { + state: PreviewLifecycleState; + mode: "background" | "foreground" | "unknown"; + projectName: string; + projectDir: string; + host: string; + port: number; + pid: number | null; + serverUrl: string; + studioUrl: string; + ready: boolean; + logPath?: string; +} + +export type PreviewLifecycleResult = + | PreviewLifecycleSession + | { state: "not-running"; projectDir?: string } + | { state: "stopped"; projectDir: string } + | { state: "listed"; sessions: readonly PreviewLifecycleSession[] } + | { state: "killed-all"; stopped: number }; + +export interface PreviewLifecyclePayload { + schemaVersion: 1; + operation: PreviewLifecycleOperation; + ok: true; + result: PreviewLifecycleResult; +} + +export interface PreviewLifecycleFailurePayload { + schemaVersion: 1; + operation: PreviewLifecycleOperation; + ok: false; + error: { code: string; message: string }; +} + +export function lifecyclePayload( + operation: PreviewLifecycleOperation, + result: PreviewLifecycleResult, +): PreviewLifecyclePayload { + return { schemaVersion: 1, operation, ok: true, result }; +} + +export function lifecycleFailurePayload( + operation: PreviewLifecycleOperation, + code: string, + message: string, +): PreviewLifecycleFailurePayload { + return { schemaVersion: 1, operation, ok: false, error: { code, message } }; +} + +export function writeLifecycleJson( + payload: PreviewLifecyclePayload | PreviewLifecycleFailurePayload, +): void { + console.log(JSON.stringify(payload)); +} From 458fe7b33897c336674e144c4bdd2be198ed8dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 15 Aug 2026 02:35:21 +0000 Subject: [PATCH 11/16] test(cli): pin preview ownership and cleanup guarantees --- .../cli/src/commands/previewLifecycle.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/cli/src/commands/previewLifecycle.test.ts b/packages/cli/src/commands/previewLifecycle.test.ts index d3b99600db..77dd93d6ba 100644 --- a/packages/cli/src/commands/previewLifecycle.test.ts +++ b/packages/cli/src/commands/previewLifecycle.test.ts @@ -141,6 +141,25 @@ describe("background preview lifecycle", () => { expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(true); }); + it("reaps a detached child that never becomes reachable without recording ownership", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + const kill = vi.fn(); + + await expect( + startBackgroundPreview(projectDir, 3002, { + scan: async () => [], + spawn: () => ({ pid: 4321, unref: vi.fn() }), + sleep: async () => {}, + kill, + stateHome, + }), + ).rejects.toThrow(/did not become ready/i); + + expect(kill).toHaveBeenCalledOnce(); + expect(kill).toHaveBeenCalledWith(4321); + expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false); + }); + it("removes a stale session when no matching server or process survives", async () => { const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); writePreviewSession( From 26cb04ffd2b5489f529cc6d73690cc45bbfd58ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 15 Aug 2026 02:36:18 +0000 Subject: [PATCH 12/16] docs(cli): explain agent-safe preview defaults --- docs/packages/cli.mdx | 11 +++++++++++ packages/cli/README.md | 10 +++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index ffb25e040b..9ef5192800 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -438,6 +438,8 @@ Start a live preview server with hot reload. npx hyperframes preview [dir] npx hyperframes preview --port 4567 npx hyperframes preview --background # keep running after the command exits +npx hyperframes preview --foreground # stay attached in a non-interactive shell +npx hyperframes preview --status --json # inspect a managed preview from an agent npx hyperframes preview --list # every running preview ``` @@ -446,6 +448,8 @@ npx hyperframes preview --list # every running preview | `--port` | Server port (default 3002) | | `--open` / `--no-open` | Open a browser, or leave it closed | | `--background` | Keep an embedded preview running after the command exits | +| `--foreground` | Keep the preview attached even when the shell is non-interactive | +| `--json` | Emit one versioned JSON result for managed start, status, stop, list, and kill-all operations | | `--browser-gpu` / `--no-browser-gpu` | Hardware GPU for Studio thumbnails and frame capture, or deterministic SwiftShader (default: auto-detect) | | `--proxy` / `--no-proxy` | Auto-transcode browser-hostile codecs (HEVC, ProRes, AV1) to a cached authoring proxy (default: on) | | `--browser-path` | Open a specific browser. `--user-data-dir`, `--remote-debugging-port`, and `--browser-no-gpu` require it. | @@ -455,6 +459,13 @@ background preview, `--list` and `--kill-all` act on all of them, and `--force-new` starts a second server for a project that already has one. Each exits straight after. +Bare `preview` chooses the safest lifecycle for its caller: it stays in the +foreground in a human interactive terminal, while a non-interactive or agent +shell starts a managed background preview. Re-running the command for the same +project reuses the healthy preview. Every start or status result includes the +exact Studio project URL as well as the underlying server URL, so agents can +hand off the intended project without guessing from the port. + To read a running Studio from a script: `--selection` prints the selected element and `--context` prints the agent-readable context, both with `--json`. Narrow the context with `--context-fields` (`server`, `selection`, `lint`, diff --git a/packages/cli/README.md b/packages/cli/README.md index fffe6cc26e..9a2b2ac527 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -33,11 +33,19 @@ Start the live preview studio in your browser: ```bash npx hyperframes preview -# Studio running at http://localhost:3002 +# Studio: http://localhost:3002/?project=%2Fabsolute%2Fpath%2Fto%2Fmy-video +# Server: http://localhost:3002 npx hyperframes preview --port 4567 ``` +In an interactive terminal, the preview stays attached until you press +Ctrl+C. In a non-interactive shell such as a coding-agent session, the same +command starts a managed preview that survives after the command returns. Use +`--background` or `--foreground` to choose explicitly, and manage persistent +previews with `--status`, `--stop`, `--list`, and `--kill-all`. Add `--json` to +managed lifecycle commands for machine-readable output. + ### `render` Render a composition to MP4. Run from the project directory; the positional From 81581618d719fb193e9478a506df65fb81a49992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 15 Aug 2026 02:43:52 +0000 Subject: [PATCH 13/16] fix(cli): prevent recursive managed preview launch --- packages/cli/src/commands/previewLifecycle.test.ts | 5 +++-- packages/cli/src/commands/previewLifecycle.ts | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/previewLifecycle.test.ts b/packages/cli/src/commands/previewLifecycle.test.ts index 77dd93d6ba..072c8b0078 100644 --- a/packages/cli/src/commands/previewLifecycle.test.ts +++ b/packages/cli/src/commands/previewLifecycle.test.ts @@ -48,7 +48,7 @@ describe("background preview lifecycle", () => { ); }); - it("builds a detached child invocation without recursively preserving --background", () => { + it("forces the detached child foreground without inheriting launcher-only flags", () => { expect( buildBackgroundPreviewArgs([ "/opt/hyperframes/cli.js", @@ -56,8 +56,9 @@ describe("background preview lifecycle", () => { projectDir, "--background", "--open", + "--json", ]), - ).toEqual(["/opt/hyperframes/cli.js", "preview", projectDir, "--no-open"]); + ).toEqual(["/opt/hyperframes/cli.js", "preview", projectDir, "--foreground", "--no-open"]); }); it("reuses an already-running server for the same project", async () => { diff --git a/packages/cli/src/commands/previewLifecycle.ts b/packages/cli/src/commands/previewLifecycle.ts index 20d26eab32..b7f3ef8505 100644 --- a/packages/cli/src/commands/previewLifecycle.ts +++ b/packages/cli/src/commands/previewLifecycle.ts @@ -171,10 +171,13 @@ export function buildBackgroundPreviewArgs(argv: string[]): string[] { (arg) => arg !== "--background" && !arg.startsWith("--background=") && + arg !== "--foreground" && + !arg.startsWith("--foreground=") && arg !== "--open" && - arg !== "--no-open", + arg !== "--no-open" && + arg !== "--json", ); - return [...filtered, "--no-open"]; + return [...filtered, "--foreground", "--no-open"]; } export async function readBackgroundPreviewStatus( From 6017dd84ede2557f5f7beb3f84bd08a521f370ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 15 Aug 2026 03:12:57 +0000 Subject: [PATCH 14/16] fix(cli): harden managed preview lifecycle --- packages/cli/README.md | 5 +- packages/cli/src/commands/preview.test.ts | 129 +++++- packages/cli/src/commands/preview.ts | 426 +++++++++++++----- .../cli/src/commands/previewLifecycle.test.ts | 107 ++++- packages/cli/src/commands/previewLifecycle.ts | 93 +++- packages/cli/src/utils/orphanCleanup.test.ts | 20 +- packages/cli/src/utils/orphanCleanup.ts | 26 +- skills-manifest.json | 2 +- .../references/preview-render.md | 7 +- 9 files changed, 639 insertions(+), 176 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 9a2b2ac527..a1dc03e5c4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -33,7 +33,7 @@ Start the live preview studio in your browser: ```bash npx hyperframes preview -# Studio: http://localhost:3002/?project=%2Fabsolute%2Fpath%2Fto%2Fmy-video +# Studio: http://localhost:3002/#project/my-video # Server: http://localhost:3002 npx hyperframes preview --port 4567 @@ -44,7 +44,8 @@ Ctrl+C. In a non-interactive shell such as a coding-agent session, the same command starts a managed preview that survives after the command returns. Use `--background` or `--foreground` to choose explicitly, and manage persistent previews with `--status`, `--stop`, `--list`, and `--kill-all`. Add `--json` to -managed lifecycle commands for machine-readable output. +managed lifecycle commands for machine-readable output. `--foreground --json` +prints the ready-session envelope once, then remains attached until stopped. ### `render` diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts index f13329151a..47b02c3625 100644 --- a/packages/cli/src/commands/preview.test.ts +++ b/packages/cli/src/commands/preview.test.ts @@ -2,7 +2,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { runCommand } from "citty"; import { + default as previewCommand, + foregroundPreviewReadyPayload, previewLaunchMode, previewLaunchModeError, previewViteArgs, @@ -16,6 +19,7 @@ const tempDirs: string[] = []; afterEach(() => { for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); }); function projectWith(storyboard: string | null, frameFiles: string[] = []): string { @@ -78,6 +82,13 @@ describe("Studio handoff URLs", () => { "http://127.0.0.1:3002/?view=storyboard#project/demo", ); }); + + it("URL-encodes project names that have hash-route metacharacters", () => { + const dir = projectWith(null); + expect(studioDeepLink("http://127.0.0.1:3002", "Launch #1? 50%", dir)).toBe( + "http://127.0.0.1:3002/#project/Launch%20%231%3F%2050%25", + ); + }); }); describe("previewLaunchMode", () => { @@ -136,12 +147,37 @@ describe("previewLaunchMode", () => { expect(previewLaunchMode(options)).toBe(expected); }); - it("rejects conflicting lifecycle overrides", () => { - expect(previewLaunchModeError({ background: true, foreground: true })).toBe( - "--background and --foreground cannot be used together", - ); - expect(previewLaunchModeError({ background: true, foreground: false })).toBeNull(); - expect(previewLaunchModeError({ background: false, foreground: true })).toBeNull(); + it("rejects conflicting lifecycle overrides and actions", () => { + expect( + previewLaunchModeError({ + background: true, + foreground: true, + status: false, + stop: false, + list: false, + killAll: false, + }), + ).toBe("--background and --foreground cannot be used together"); + expect( + previewLaunchModeError({ + background: false, + foreground: false, + status: true, + stop: true, + list: false, + killAll: false, + }), + ).toBe("Only one of --status, --stop, --list, or --kill-all can be used at a time"); + expect( + previewLaunchModeError({ + background: true, + foreground: false, + status: false, + stop: false, + list: false, + killAll: false, + }), + ).toBeNull(); }); it("pins detached Vite to the port the lifecycle scanner waits on", () => { @@ -149,6 +185,87 @@ describe("previewLaunchMode", () => { }); }); +describe("preview lifecycle JSON failures", () => { + it("wraps managed-start validation failures in one JSON document", async () => { + const dir = projectWith(null); + writeFileSync(join(dir, "index.html"), ""); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runCommand(previewCommand, { + rawArgs: [dir, "--background", "--json", "--user-data-dir", join(dir, "profile")], + }); + + expect(log).toHaveBeenCalledOnce(); + const [line] = log.mock.calls[0] as [string]; + expect(JSON.parse(line)).toMatchObject({ + schemaVersion: 1, + operation: "start", + ok: false, + error: { code: "preview-validation-failed" }, + }); + }); + + it("wraps stop failures in one JSON document", async () => { + const missing = join(tmpdir(), `hf-preview-missing-${process.pid}-${Date.now()}`); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await runCommand(previewCommand, { + rawArgs: [missing, "--stop", "--json"], + }); + + expect(log).toHaveBeenCalledOnce(); + const [line] = log.mock.calls[0] as [string]; + expect(JSON.parse(line)).toMatchObject({ + schemaVersion: 1, + operation: "stop", + ok: false, + error: { code: "preview-stop-failed" }, + }); + expect(error).not.toHaveBeenCalled(); + }); + + it("wraps missing-project start failures without human stderr", async () => { + const missing = join(tmpdir(), `hf-preview-missing-start-${process.pid}-${Date.now()}`); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await runCommand(previewCommand, { rawArgs: [missing, "--background", "--json"] }); + + expect(log).toHaveBeenCalledOnce(); + const [line] = log.mock.calls[0] as [string]; + expect(JSON.parse(line)).toMatchObject({ + operation: "start", + ok: false, + error: { code: "preview-start-failed" }, + }); + expect(error).not.toHaveBeenCalled(); + }); +}); + +describe("foreground preview JSON", () => { + it("emits the same ready session contract before remaining attached", () => { + const dir = projectWith(null); + expect(foregroundPreviewReadyPayload("Launch #1", "http://localhost:4567", dir, 4321)).toEqual({ + schemaVersion: 1, + operation: "start", + ok: true, + result: { + state: "started", + mode: "foreground", + projectName: "Launch #1", + projectDir: dir, + host: "127.0.0.1", + port: 4567, + pid: 4321, + serverUrl: "http://127.0.0.1:4567", + studioUrl: "http://127.0.0.1:4567/#project/Launch%20%231", + ready: true, + }, + }); + }); +}); + describe("waitForStudioChildClose", () => { it("resolves when the child closed before the listener was attached", async () => { const signalTarget = { once: vi.fn(), off: vi.fn() }; diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 518bda7396..43aa1ba51b 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -62,10 +62,11 @@ import { type FindPortResult, } from "../server/portUtils.js"; import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js"; -import { resolveProject } from "../utils/project.js"; +import { resolveProject, resolveProjectOrThrow } from "../utils/project.js"; import { resolveAutoProxy } from "../utils/projectConfig.js"; import { studioProxyEnv } from "../utils/studioProxyEnv.js"; import { + listBackgroundPreviewStatuses, readBackgroundPreviewStatus, startBackgroundPreview, stopBackgroundPreview, @@ -74,6 +75,8 @@ import { lifecycleFailurePayload, lifecyclePayload, writeLifecycleJson, + type PreviewLifecycleOperation, + type PreviewLifecyclePayload, type PreviewLifecycleSession, } from "./previewLifecycleOutput.js"; import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js"; @@ -91,6 +94,7 @@ interface StudioLaunchOptions extends BrowserLaunchOptions { autoProxy?: boolean; browserGpuMode?: BrowserGpuMode; port?: number; + json?: boolean; } interface EmbeddedStudioOptions extends StudioLaunchOptions { @@ -122,10 +126,21 @@ type CompactSelectionPayload = Pick< const DEFAULT_CONTEXT_FIELDS: ContextField[] = ["server", "selection", "lint", "capabilities"]; export default defineCommand({ - meta: { name: "preview", description: "Start the studio for previewing compositions" }, + meta: { + name: "preview", + description: "Start the studio for previewing compositions", + }, args: { - dir: { type: "positional", description: "Project directory", required: false }, - port: { type: "string", description: "Port to run the preview server on", default: "3002" }, + dir: { + type: "positional", + description: "Project directory", + required: false, + }, + port: { + type: "string", + description: "Port to run the preview server on", + default: "3002", + }, "force-new": { type: "boolean", description: "Start a new server even if one is already running for this project", @@ -227,11 +242,27 @@ export default defineCommand({ const launchModeError = previewLaunchModeError({ background: Boolean(args.background), foreground: Boolean(args.foreground), + status: Boolean(args.status), + stop: Boolean(args.stop), + list: Boolean(args.list), + killAll: Boolean(args["kill-all"]), }); if (launchModeError) { if (args.json) { writeLifecycleJson( - lifecycleFailurePayload("start", "conflicting-lifecycle-flags", launchModeError), + lifecycleFailurePayload( + args.status + ? "status" + : args.stop + ? "stop" + : args.list + ? "list" + : args["kill-all"] + ? "kill-all" + : "start", + "conflicting-lifecycle-flags", + launchModeError, + ), ); } else { clack.log.error(launchModeError); @@ -247,64 +278,96 @@ export default defineCommand({ const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined; if (args.status || args.stop) { - const project = resolveProject(args.dir); - if (args.stop) { - const stopped = await stopBackgroundPreview(project.dir, startPort); - if (args.json) { - writeLifecycleJson( - lifecyclePayload( - "stop", + try { + const project = args.json ? resolveProjectOrThrow(args.dir) : resolveProject(args.dir); + if (args.stop) { + const stopped = await stopBackgroundPreview(project.dir, startPort); + if (args.json) { + writeLifecycleJson( + lifecyclePayload( + "stop", + stopped + ? { state: "stopped", projectDir: project.dir } + : { state: "not-running", projectDir: project.dir }, + ), + ); + } else { + console.log( stopped - ? { state: "stopped", projectDir: project.dir } - : { state: "not-running", projectDir: project.dir }, - ), - ); - } else { - console.log( - stopped - ? `\n ${c.success("Stopped background preview")} ${c.dim(project.dir)}\n` - : `\n ${c.dim("No background preview is running for")} ${project.dir}\n`, - ); + ? `\n ${c.success("Stopped background preview")} ${c.dim(project.dir)}\n` + : `\n ${c.dim("No background preview is running for")} ${project.dir}\n`, + ); + } + return; + } + const status = await readBackgroundPreviewStatus(project.dir, startPort); + if (!status) { + if (args.json) { + writeLifecycleJson( + lifecyclePayload("status", { + state: "not-running", + projectDir: project.dir, + }), + ); + } else { + console.log(`\n ${c.dim("No background preview is running for")} ${project.dir}\n`); + } + return; } - return; - } - const status = await readBackgroundPreviewStatus(project.dir, startPort); - if (!status) { if (args.json) { writeLifecycleJson( - lifecyclePayload("status", { state: "not-running", projectDir: project.dir }), + lifecyclePayload( + "status", + previewLifecycleSession({ + state: "running", + mode: "background", + projectName: project.name, + projectDir: project.dir, + port: status.port, + pid: status.pid, + logPath: status.logPath, + }), + ), ); - } else { - console.log(`\n ${c.dim("No background preview is running for")} ${project.dir}\n`); + return; } + printStudioSummary(project.name, previewBaseUrl(status.port), project.dir, { + details: [`Background preview running (PID ${status.pid}).`, `Log: ${status.logPath}`], + }); return; - } - if (args.json) { - writeLifecycleJson( - lifecyclePayload( - "status", - previewLifecycleSession({ - state: "running", - mode: "background", - projectName: project.name, - projectDir: project.dir, - port: status.port, - pid: status.pid, - logPath: status.logPath, - }), - ), + } catch (error) { + reportPreviewFailure( + Boolean(args.json), + args.stop ? "stop" : "status", + args.stop ? "preview-stop-failed" : "preview-status-failed", + errorMessage(error), ); return; } - printStudioSummary(project.name, previewBaseUrl(status.port), project.dir, { - details: [`Background preview running (PID ${status.pid}).`, `Log: ${status.logPath}`], - }); - return; } // --list: scan and display active servers if (args.list) { - const servers = await scanActiveServers(startPort); + const [scannedServers, managedSessions] = await Promise.all([ + scanActiveServers(startPort), + listBackgroundPreviewStatuses(), + ]); + const managedKeys = new Set( + managedSessions.map((session) => `${resolve(session.projectDir)}\0${session.port}`), + ); + const servers = [ + ...managedSessions.map((session) => ({ + port: session.port, + host: "127.0.0.1", + projectName: basename(session.projectDir), + projectDir: session.projectDir, + version: "managed", + pid: String(session.pid), + })), + ...scannedServers.filter( + (server) => !managedKeys.has(`${resolve(server.projectDir)}\0${server.port}`), + ), + ]; if (args.json) { writeLifecycleJson( lifecyclePayload("list", { @@ -341,8 +404,13 @@ export default defineCommand({ // --kill-all: kill all active servers if (args["kill-all"]) { - const servers = await scanActiveServers(startPort); - if (servers.length === 0) { + const managedSessions = await listBackgroundPreviewStatuses(); + let killed = 0; + for (const session of managedSessions) { + if (await stopBackgroundPreview(session.projectDir, session.port)) killed++; + } + killed += await killActiveServers(startPort); + if (killed === 0) { if (args.json) { writeLifecycleJson(lifecyclePayload("kill-all", { state: "killed-all", stopped: 0 })); } else { @@ -350,9 +418,13 @@ export default defineCommand({ } return; } - const killed = await killActiveServers(startPort); if (args.json) { - writeLifecycleJson(lifecyclePayload("kill-all", { state: "killed-all", stopped: killed })); + writeLifecycleJson( + lifecyclePayload("kill-all", { + state: "killed-all", + stopped: killed, + }), + ); } else { console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.\n`); } @@ -389,7 +461,18 @@ export default defineCommand({ const rawArg = args.dir; const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./"; - const project = resolveProject(rawArg); + let project; + try { + project = args.json ? resolveProjectOrThrow(rawArg) : resolveProject(rawArg); + } catch (error) { + reportPreviewFailure( + Boolean(args.json), + "start", + "preview-start-failed", + errorMessage(error), + ); + return; + } const dir = project.dir; const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : project.name; @@ -403,8 +486,12 @@ export default defineCommand({ // Validation: --user-data-dir requires --browser-path if (args["user-data-dir"] && !args["browser-path"]) { - clack.log.error("--user-data-dir requires --browser-path"); - setCommandExitCode(1); + reportPreviewFailure( + Boolean(args.json), + "start", + "preview-validation-failed", + "--user-data-dir requires --browser-path", + ); return; } // Validation: --remote-debugging-port deps @@ -414,8 +501,7 @@ export default defineCommand({ remoteDebuggingPort: args["remote-debugging-port"] as string | undefined, }); if (depsError) { - clack.log.error(depsError); - setCommandExitCode(1); + reportPreviewFailure(Boolean(args.json), "start", "preview-validation-failed", depsError); return; } @@ -423,10 +509,12 @@ export default defineCommand({ const browserPath = args["browser-path"] as string | undefined; const browserNoGpu = !!args["browser-no-gpu"]; if (browserNoGpu && !browserPath) { - clack.log.error( + reportPreviewFailure( + Boolean(args.json), + "start", + "preview-validation-failed", "--browser-no-gpu requires --browser-path (the system default browser cannot receive Chromium flags — use --no-open on GPU-unstable hosts)", ); - setCommandExitCode(1); return; } const userDataDir = args["user-data-dir"] as string | undefined; @@ -436,8 +524,12 @@ export default defineCommand({ args["remote-debugging-port"] as string | undefined, ); } catch (err) { - clack.log.error((err as Error).message); - setCommandExitCode(1); + reportPreviewFailure( + Boolean(args.json), + "start", + "preview-validation-failed", + (err as Error).message, + ); return; } // Resolve once so embedded, monorepo-dev, and locally installed Studio @@ -452,18 +544,6 @@ export default defineCommand({ localStudio: hasLocalStudio(dir), }); - if (args.json && launchMode !== "background") { - writeLifecycleJson( - lifecycleFailurePayload( - "start", - "foreground-json-unsupported", - "--json requires a managed preview; remove --foreground or use --background", - ), - ); - setCommandExitCode(1); - return; - } - if (launchMode === "background") { let background; try { @@ -530,6 +610,7 @@ export default defineCommand({ autoProxy, browserGpuMode, port: startPort, + json: Boolean(args.json), }); } @@ -545,6 +626,7 @@ export default defineCommand({ autoProxy, browserGpuMode, port: startPort, + json: Boolean(args.json), }); } @@ -559,6 +641,7 @@ export default defineCommand({ remoteDebuggingPort, browserNoGpu, browserGpuMode, + json: Boolean(args.json), }); }, }); @@ -581,12 +664,33 @@ export function previewLaunchMode(options: { export function previewLaunchModeError(options: { background: boolean; foreground: boolean; + status: boolean; + stop: boolean; + list: boolean; + killAll: boolean; }): string | null { - return options.background && options.foreground - ? "--background and --foreground cannot be used together" + if (options.background && options.foreground) { + return "--background and --foreground cannot be used together"; + } + const actionCount = [options.status, options.stop, options.list, options.killAll].filter( + Boolean, + ).length; + return actionCount > 1 + ? "Only one of --status, --stop, --list, or --kill-all can be used at a time" : null; } +function reportPreviewFailure( + json: boolean, + operation: PreviewLifecycleOperation, + code: string, + message: string, +): void { + if (json) writeLifecycleJson(lifecycleFailurePayload(operation, code, message)); + else clack.log.error(message); + setCommandExitCode(1); +} + export function previewViteArgs(port: number | undefined): string[] { return ["--host", "127.0.0.1", ...(port === undefined ? [] : ["--port", String(port)])]; } @@ -769,7 +873,12 @@ function countLintFindings(findings: Array<{ severity: string }>): { async function printCurrentContext( projectDir: string, startPort: number, - options: { json: boolean; fields?: string; detail?: string; preferredPort?: number }, + options: { + json: boolean; + fields?: string; + detail?: string; + preferredPort?: number; + }, ): Promise { let fields: ContextField[]; try { @@ -856,7 +965,10 @@ async function printCurrentContext( ok: false as const, error: selectionResult.status === "rejected" - ? { code: "selection-unavailable", message: errorMessage(selectionResult.reason) } + ? { + code: "selection-unavailable", + message: errorMessage(selectionResult.reason), + } : { code: "no-selection", message: "Studio is running, but no element is selected.", @@ -874,8 +986,14 @@ async function printCurrentContext( ok: false as const, error: lintResult.status === "rejected" - ? { code: "lint-unavailable", message: errorMessage(lintResult.reason) } - : { code: "lint-not-requested", message: "Lint was not requested." }, + ? { + code: "lint-unavailable", + message: errorMessage(lintResult.reason), + } + : { + code: "lint-not-requested", + message: "Lint was not requested.", + }, }; const payload: Record = { ok: true }; @@ -972,7 +1090,7 @@ export function studioLandingSearch(projectDir: string): string { // plus the project hash route. `url` never carries a trailing slash (both the // embedded server and the Vite `Local:` match strip it). export function studioDeepLink(url: string, projectName: string, projectDir: string): string { - return `${url}/${studioLandingSearch(projectDir)}#project/${projectName}`; + return `${url}/${studioLandingSearch(projectDir)}#project/${encodeURIComponent(projectName)}`; } export function studioSummaryUrls( @@ -986,6 +1104,26 @@ export function studioSummaryUrls( }; } +export function foregroundPreviewReadyPayload( + projectName: string, + serverUrl: string, + projectDir: string, + pid: number | null, +): PreviewLifecyclePayload { + const port = Number(new URL(serverUrl).port); + return lifecyclePayload( + "start", + previewLifecycleSession({ + state: "started", + mode: "foreground", + projectName, + projectDir, + port, + pid, + }), + ); +} + function previewLifecycleSession(options: { state: PreviewLifecycleSession["state"]; mode: PreviewLifecycleSession["mode"]; @@ -1125,7 +1263,7 @@ function attachStudioReadyHandler( spinner: ReturnType, projectName: string, projectDir: string, - options?: BrowserLaunchOptions, + options?: StudioLaunchOptions, ): void { let detected = false; @@ -1134,8 +1272,16 @@ function attachStudioReadyHandler( if (!url || detected) return; detected = true; - spinner.stop(c.success("Studio running")); - printStudioSummary(projectName, url, projectDir, { footer: "Press Ctrl+C to stop" }); + if (options?.json) { + writeLifecycleJson( + foregroundPreviewReadyPayload(projectName, url, projectDir, child.pid ?? null), + ); + } else { + spinner.stop(c.success("Studio running")); + printStudioSummary(projectName, url, projectDir, { + footer: "Press Ctrl+C to stop", + }); + } openStudioBrowser(url, projectName, projectDir, options); child.stdout.removeListener("data", handleOutput); child.stderr.removeListener("data", handleOutput); @@ -1144,8 +1290,12 @@ function attachStudioReadyHandler( child.stdout.on("data", handleOutput); child.stderr.on("data", handleOutput); child.on("error", (err) => { - spinner.stop(c.error("Failed to start studio")); - console.error(c.dim(err.message)); + if (options?.json) { + reportPreviewFailure(true, "start", "preview-start-failed", err.message); + } else { + spinner.stop(c.error("Failed to start studio")); + console.error(c.dim(err.message)); + } }); } @@ -1162,10 +1312,10 @@ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise` only targets this process — the child tree (Vite + Chrome) // would survive without explicit cleanup. - // On Windows, killProcessTree is a no-op (pgrep/ps unavailable); Ctrl+C - // propagates via the console process group instead. + // On Windows, killProcessTree delegates to taskkill /T so descendants are + // reaped even when the console signal reaches only this wrapper. return waitForStudioChildClose(child); } @@ -1217,9 +1367,9 @@ async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): P const projectsDir = join(studioPkgPath, "data", "projects"); const { symlinkPath, createdSymlink } = linkProjectIntoStudioData(dir, projectsDir, pName); - clack.intro(c.bold("hyperframes preview") + c.dim(" (local studio)")); + if (!options?.json) clack.intro(c.bold("hyperframes preview") + c.dim(" (local studio)")); const s = clack.spinner(); - s.start("Starting studio..."); + if (!options?.json) s.start("Starting studio..."); const viteCommand = buildNpxCommand(["vite", ...previewViteArgs(options?.port)]); const child = spawn(viteCommand.command, viteCommand.args, { @@ -1235,7 +1385,7 @@ async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): P attachStudioReadyHandler(child, s, pName, dir, options); removeSymlinkOnExit(createdSymlink, symlinkPath); - // Same tree-kill handler as dev mode. No-op on Windows (see comment above). + // Same cross-platform tree-kill handler as dev mode. return waitForStudioChildClose(child); } @@ -1257,20 +1407,24 @@ async function runEmbeddedMode( const pName = options?.projectName ?? basename(dir); const studioBundle = resolveStudioBundle(); - clack.intro(c.bold("hyperframes preview")); + if (!options?.json) clack.intro(c.bold("hyperframes preview")); const s = clack.spinner(); - s.start("Starting studio..."); + if (!options?.json) s.start("Starting studio..."); if (!studioBundle.available) { - s.stop(c.error("Studio build missing")); - console.error(); - console.error(` ${c.dim("Could not find")} ${c.accent("index.html")} ${c.dim("in:")}`); - for (const checkedPath of studioBundle.checkedPaths) { - console.error(` ${c.dim("-")} ${checkedPath}`); + if (options?.json) { + reportPreviewFailure(true, "start", "preview-start-failed", "Studio build missing"); + } else { + s.stop(c.error("Studio build missing")); + console.error(); + console.error(` ${c.dim("Could not find")} ${c.accent("index.html")} ${c.dim("in:")}`); + for (const checkedPath of studioBundle.checkedPaths) { + console.error(` ${c.dim("-")} ${checkedPath}`); + } + console.error(); + console.error(` ${c.dim("Rebuild the CLI package with")} ${c.accent("bun run build")}`); + console.error(); } - console.error(); - console.error(` ${c.dim("Rebuild the CLI package with")} ${c.accent("bun run build")}`); - console.error(); setCommandExitCode(1); return; } @@ -1295,38 +1449,59 @@ async function runEmbeddedMode( options?.browserGpuMode, ); } catch (err: unknown) { - s.stop(c.error("Failed to start studio")); - console.error(); - console.error(` ${(err as Error).message}`); - console.error(); - setCommandExitCode(1); + reportPreviewFailure( + Boolean(options?.json), + "start", + "preview-start-failed", + (err as Error).message, + ); return; } if (result.type === "already-running") { const url = `http://localhost:${result.port}`; - s.stop(c.success("Already running")); - printStudioSummary(pName, url, dir, { - details: ["Reusing existing server. Use --force-new to start a fresh instance."], - }); + if (options?.json) { + writeLifecycleJson( + lifecyclePayload( + "start", + previewLifecycleSession({ + state: "reused", + mode: "foreground", + projectName: pName, + projectDir: dir, + port: result.port, + pid: null, + }), + ), + ); + } else { + s.stop(c.success("Already running")); + printStudioSummary(pName, url, dir, { + details: ["Reusing existing server. Use --force-new to start a fresh instance."], + }); + } openStudioBrowser(url, pName, dir, options); return; } const url = `http://localhost:${result.port}`; - s.stop(c.success("Studio running")); - console.log(); - if (result.port !== startPort) { - console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`); + if (options?.json) { + writeLifecycleJson(foregroundPreviewReadyPayload(pName, url, dir, process.pid)); + } else { + s.stop(c.success("Studio running")); console.log(); + if (result.port !== startPort) { + console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`); + console.log(); + } + printStudioSummary(pName, url, dir, { + details: [ + "Edit with your AI agent — it has HyperFrames skills installed.", + "Changes reload automatically in the studio.", + ], + footer: "Press Ctrl+C to stop", + }); } - printStudioSummary(pName, url, dir, { - details: [ - "Edit with your AI agent — it has HyperFrames skills installed.", - "Changes reload automatically in the studio.", - ], - footer: "Press Ctrl+C to stop", - }); openStudioBrowser(url, pName, dir, options); // Block until Ctrl+C. Node would normally exit on SIGINT, but the listening @@ -1342,7 +1517,10 @@ async function runEmbeddedMode( let rl: import("node:readline").Interface | undefined; if (process.platform === "win32") { const readline = await import("node:readline"); - rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); rl.on("SIGINT", () => { process.emit("SIGINT", "SIGINT"); }); diff --git a/packages/cli/src/commands/previewLifecycle.test.ts b/packages/cli/src/commands/previewLifecycle.test.ts index 072c8b0078..132a256f49 100644 --- a/packages/cli/src/commands/previewLifecycle.test.ts +++ b/packages/cli/src/commands/previewLifecycle.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ActiveServer } from "../server/portUtils.js"; import { buildBackgroundPreviewArgs, + listBackgroundPreviewStatuses, previewSessionPath, readBackgroundPreviewStatus, startBackgroundPreview, @@ -77,6 +78,77 @@ describe("background preview lifecycle", () => { expect(spawn).not.toHaveBeenCalled(); }); + it("reuses a saved managed preview on a custom port without repeating --port", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + writePreviewSession( + { pid: 4321, port: 41402, projectDir, logPath: "/tmp/custom.log" }, + stateHome, + ); + const customServer = { ...server, port: 41402 }; + const scan = vi.fn(async (startPort?: number) => (startPort === 41402 ? [customServer] : [])); + const spawn = vi.fn(); + + const result = await startBackgroundPreview(projectDir, 3002, { + scan, + spawn, + stateHome, + }); + + expect(result).toMatchObject({ type: "reused", port: 41402, pid: 4321 }); + expect(scan).toHaveBeenCalledWith(41402); + expect(spawn).not.toHaveBeenCalled(); + }); + + it("discovers managed previews outside the default port scan and removes stale records", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + const otherProjectDir = resolve("/tmp/hyperframes-preview-managed-custom-port"); + const staleProjectDir = resolve("/tmp/hyperframes-preview-managed-stale"); + writePreviewSession( + { + pid: 8765, + port: 41402, + projectDir: otherProjectDir, + logPath: "/tmp/custom.log", + }, + stateHome, + ); + writePreviewSession( + { + pid: 9999, + port: 45000, + projectDir: staleProjectDir, + logPath: "/tmp/stale.log", + }, + stateHome, + ); + + const statuses = await listBackgroundPreviewStatuses({ + stateHome, + scan: async (startPort) => + startPort === 41402 + ? [ + { + port: 41402, + projectName: "managed-custom-port", + projectDir: otherProjectDir, + version: "test", + pid: "8765", + }, + ] + : [], + }); + + expect(statuses).toEqual([ + { + pid: 8765, + port: 41402, + projectDir: otherProjectDir, + logPath: "/tmp/custom.log", + }, + ]); + expect(existsSync(previewSessionPath(staleProjectDir, stateHome))).toBe(false); + }); + it("force-new waits for a different server instead of reusing the existing one", async () => { const replacement = { ...server, port: 3211, pid: "5432" }; let scans = 0; @@ -183,7 +255,10 @@ describe("background preview lifecycle", () => { savePreviewSession(stateHome); const scan = vi.fn(async () => [server]); - const status = await readBackgroundPreviewStatus(projectDir, 3002, { scan, stateHome }); + const status = await readBackgroundPreviewStatus(projectDir, 3002, { + scan, + stateHome, + }); expect(status?.port).toBe(3210); expect(scan).toHaveBeenCalledWith(3210); @@ -224,30 +299,28 @@ describe("background preview lifecycle", () => { expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false); }); - it("uses the saved child PID when a matching live server cannot report one", async () => { + it("refuses to stop when the live server cannot prove its own PID", async () => { const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); writePreviewSession( { pid: 4321, port: 3210, projectDir, logPath: "/tmp/preview.log" }, stateHome, ); - let running = true; - const scan = vi.fn(async () => (running ? [{ ...server, pid: null }] : [])); - const kill = vi.fn(() => { - running = false; - }); + const scan = vi.fn(async () => [{ ...server, pid: null }]); + const kill = vi.fn(); - const result = await stopBackgroundPreview(projectDir, 3002, { - scan, - kill, - sleep: async () => {}, - stateHome, - }); + await expect( + stopBackgroundPreview(projectDir, 3002, { + scan, + kill, + sleep: async () => {}, + stateHome, + }), + ).rejects.toThrow(/ownership/i); - expect(result).toBe(true); - expect(kill).toHaveBeenCalledWith(4321); + expect(kill).not.toHaveBeenCalled(); }); - it("stops the detached wrapper as well as its reported Vite server", async () => { + it("does not kill an unproven saved wrapper PID when the live server reports another PID", async () => { const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); savePreviewSession(stateHome); let running = true; @@ -264,7 +337,7 @@ describe("background preview lifecycle", () => { }); expect(result).toBe(true); - expect(kill.mock.calls).toEqual([[9876], [4321]]); + expect(kill.mock.calls).toEqual([[9876]]); }); it("fails loudly when the server remains reachable after stop", async () => { diff --git a/packages/cli/src/commands/previewLifecycle.ts b/packages/cli/src/commands/previewLifecycle.ts index b7f3ef8505..80bdcda4ab 100644 --- a/packages/cli/src/commands/previewLifecycle.ts +++ b/packages/cli/src/commands/previewLifecycle.ts @@ -6,6 +6,7 @@ import { mkdirSync, openSync, readFileSync, + readdirSync, rmSync, writeFileSync, } from "node:fs"; @@ -95,6 +96,43 @@ function readPreviewSession( } } +function hasValidPreviewProcess(session: PreviewSession): boolean { + return Number.isInteger(session.pid) && session.pid > 0; +} + +function hasValidPreviewEndpoint(session: PreviewSession): boolean { + return Number.isInteger(session.port) && session.port > 0 && session.port <= 65535; +} + +function matchesPreviewSessionFile( + session: PreviewSession, + path: string, + stateHome: string, +): boolean { + return ( + typeof session.projectDir === "string" && + typeof session.logPath === "string" && + previewSessionPath(session.projectDir, stateHome) === path + ); +} + +function readPreviewSessionFile(path: string, stateHome: string): PreviewSession | null { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as PreviewSession; + if ( + !hasValidPreviewProcess(parsed) || + !hasValidPreviewEndpoint(parsed) || + !matchesPreviewSessionFile(parsed, path, stateHome) + ) { + throw new Error("invalid preview session"); + } + return parsed; + } catch { + rmSync(path, { force: true }); + return null; + } +} + function removePreviewSession(projectDir: string, stateHome = defaultStateHome()): void { rmSync(previewSessionPath(projectDir, stateHome), { force: true }); } @@ -205,6 +243,34 @@ export async function readBackgroundPreviewStatus( return null; } +export async function listBackgroundPreviewStatuses( + dependencies: LifecycleDependencies = {}, +): Promise { + const stateHome = dependencies.stateHome ?? defaultStateHome(); + const directory = sessionDirectory(stateHome); + let files: string[]; + try { + files = readdirSync(directory) + .filter((name) => name.endsWith(".json")) + .map((name) => join(directory, name)); + } catch { + return []; + } + + const saved = files + .map((path) => readPreviewSessionFile(path, stateHome)) + .filter((session): session is PreviewSession => session !== null); + const statuses = await Promise.all( + saved.map((session) => + readBackgroundPreviewStatus(session.projectDir, session.port, { + ...dependencies, + stateHome, + }), + ), + ); + return statuses.filter((status): status is PreviewSession => status !== null); +} + export async function startBackgroundPreview( projectDir: string, startPort: number, @@ -214,7 +280,10 @@ export async function startBackgroundPreview( | { type: "started"; port: number; pid: number; logPath: string } > { const scan = dependencies.scan ?? scanActiveServers; - const existing = matchingServer(await scan(startPort), projectDir, dependencies.browserGpuMode); + const stateHome = dependencies.stateHome ?? defaultStateHome(); + const saved = readPreviewSession(projectDir, stateHome); + const scanStart = dependencies.forceNew ? startPort : (saved?.port ?? startPort); + const existing = matchingServer(await scan(scanStart), projectDir, dependencies.browserGpuMode); if (existing && !dependencies.forceNew) { return { type: "reused", @@ -224,7 +293,6 @@ export async function startBackgroundPreview( }; } - const stateHome = dependencies.stateHome ?? defaultStateHome(); const { pid, logPath } = spawnDetachedPreview(projectDir, stateHome, dependencies); const sleep = dependencies.sleep ?? delay; @@ -263,26 +331,21 @@ export async function stopBackgroundPreview( const saved = readPreviewSession(projectDir, stateHome); const scanStart = saved?.port ?? startPort; const server = matchingServer(await scan(scanStart), projectDir); - // A saved PID can be reused after a crashed preview, so only trust it while - // a currently reachable server proves this exact project is still running. - const pid = Number(server ? (server.pid ?? saved?.pid) : undefined); - if (!Number.isInteger(pid) || pid <= 0) { + if (!server) { removePreviewSession(projectDir, stateHome); return false; } + // A saved PID can be reused after a crashed preview. The HTTP probe proves + // the project, but only the live server's own metadata proves which process + // owns it; never substitute the saved wrapper PID here. + const pid = Number(server.pid); + if (!Number.isInteger(pid) || pid <= 0) { + throw new Error(`preview ownership could not be proven for ${resolve(projectDir)}`); + } const kill = dependencies.kill ?? stopProcess; kill(pid); - // Dev/local mode exposes Vite's PID, while the session records the detached - // CLI wrapper. Both must be reaped: killing only Vite closes the port but can - // leave the wrapper waiting on inherited stdio forever. The live matching - // server above is the ownership proof that makes the saved PID safe to use. - const wrapperPid = Number(saved?.pid); - if (Number.isInteger(wrapperPid) && wrapperPid > 0 && wrapperPid !== pid) { - kill(wrapperPid); - } - const sleep = dependencies.sleep ?? delay; for (let attempt = 0; attempt < 25; attempt++) { if (!matchingServer(await scan(scanStart), projectDir)) { diff --git a/packages/cli/src/utils/orphanCleanup.test.ts b/packages/cli/src/utils/orphanCleanup.test.ts index b3dc2afdf2..d599bad2f5 100644 --- a/packages/cli/src/utils/orphanCleanup.test.ts +++ b/packages/cli/src/utils/orphanCleanup.test.ts @@ -1,13 +1,25 @@ import { describe, it, expect } from "vitest"; import { spawn } from "node:child_process"; -import { killProcessTree, killOrphanedProcesses } from "./orphanCleanup.js"; +import { + killProcessTree, + killOrphanedProcesses, + windowsProcessTreeKillArgs, +} from "./orphanCleanup.js"; const IS_UNIX = process.platform !== "win32"; +describe("Windows process-tree cleanup", () => { + it("uses taskkill recursively and forcefully for the owned PID", () => { + expect(windowsProcessTreeKillArgs(4321)).toEqual(["/PID", "4321", "/T", "/F"]); + }); +}); + describe.skipIf(!IS_UNIX)("killProcessTree", () => { it("kills a process and all its children", async () => { // Spawn a parent that spawns two sleeping children - const parent = spawn("bash", ["-c", "sleep 60 & sleep 60 & wait"], { stdio: "ignore" }); + const parent = spawn("bash", ["-c", "sleep 60 & sleep 60 & wait"], { + stdio: "ignore", + }); // Let children spawn await new Promise((r) => setTimeout(r, 200)); @@ -27,7 +39,9 @@ describe.skipIf(!IS_UNIX)("killProcessTree", () => { it("escalates to SIGKILL after grace period", async () => { // Spawn a process that traps SIGTERM - const proc = spawn("bash", ["-c", "trap '' TERM; sleep 60"], { stdio: "ignore" }); + const proc = spawn("bash", ["-c", "trap '' TERM; sleep 60"], { + stdio: "ignore", + }); await new Promise((r) => setTimeout(r, 100)); const exitPromise = new Promise((resolve) => proc.on("close", resolve)); diff --git a/packages/cli/src/utils/orphanCleanup.ts b/packages/cli/src/utils/orphanCleanup.ts index a34f01775c..bbd1458aad 100644 --- a/packages/cli/src/utils/orphanCleanup.ts +++ b/packages/cli/src/utils/orphanCleanup.ts @@ -1,4 +1,4 @@ -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; /** * Find and kill orphaned Chrome processes from previous crashed sessions. @@ -34,11 +34,20 @@ export function killOrphanedProcesses(): number { * depth-first so children are killed before parents, preventing * re-adoption races. * - * No-op on Windows — process groups are managed differently and - * the pgrep/ps utilities are not available. + * Windows uses taskkill's tree mode because pgrep/ps are unavailable there. */ export function killProcessTree(pid: number, signal: NodeJS.Signals = "SIGTERM"): void { - if (process.platform === "win32") return; + if (process.platform === "win32") { + try { + execFileSync("taskkill", windowsProcessTreeKillArgs(pid), { + stdio: "ignore", + timeout: 5000, + }); + } catch { + // Process already exited or taskkill could not inspect it. + } + return; + } const descendants = getDescendants(pid); const allPids = [...descendants.reverse(), pid]; @@ -65,10 +74,17 @@ export function killProcessTree(pid: number, signal: NodeJS.Signals = "SIGTERM") } } +export function windowsProcessTreeKillArgs(pid: number): string[] { + return ["/PID", String(pid), "/T", "/F"]; +} + function getDescendants(pid: number): number[] { let children: number[]; try { - const raw = execSync(`pgrep -P ${pid}`, { encoding: "utf-8", timeout: 2000 }).trim(); + const raw = execSync(`pgrep -P ${pid}`, { + encoding: "utf-8", + timeout: 2000, + }).trim(); if (!raw) return []; children = raw .split("\n") diff --git a/skills-manifest.json b/skills-manifest.json index db18367224..2fc061beac 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -30,7 +30,7 @@ "files": 6 }, "hyperframes-cli": { - "hash": "8145aac05f931cc4", + "hash": "48a43f848bad1886", "files": 11 }, "hyperframes-core": { diff --git a/skills/hyperframes-cli/references/preview-render.md b/skills/hyperframes-cli/references/preview-render.md index 289d2f6322..db0e351ee2 100644 --- a/skills/hyperframes-cli/references/preview-render.md +++ b/skills/hyperframes-cli/references/preview-render.md @@ -5,8 +5,9 @@ Serve, render, and share commands. ## preview ```bash -npx hyperframes preview --background # agent-safe; survives the invoking command -npx hyperframes preview # interactive foreground session +npx hyperframes preview # foreground on a TTY; persistent in agent shells +npx hyperframes preview --background # explicit persistent session +npx hyperframes preview --foreground --json # ready JSON, then remain attached npx hyperframes preview --background --port 4567 # agent-safe 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 @@ -24,7 +25,7 @@ Use the actual port and project directory name; treat `index.html` as source-cod To land the user on the **Storyboard view** instead of the timeline, put `?view=storyboard` ahead of the hash: `http://localhost:/?view=storyboard#project/`. Hand this URL whenever the storyboard is the thing to review and nothing is assembled yet — before `index.html` exists, the timeline stage has nothing to show, so the bare project URL opens on an empty player. -Two ways a handed URL turns out dead — check both before handing it back: the URL is missing its `#project/` hash (Studio loads but has no project to open), or the server is not actually running. Agents must start it with `preview --background`: a foreground server is tied to the invoking tool session, and its exit strands the handed URL at `ERR_CONNECTION_TIMED_OUT`. Verify the URL returns HTTP 200, keep it alive for the whole review, and stop it explicitly with `npx hyperframes preview --stop` afterward. +Two ways a handed URL turns out dead — check both before handing it back: the URL is missing its `#project/` hash (Studio loads but has no project to open), or the server is not actually running. Bare `preview` automatically creates a managed persistent session in a non-TTY agent shell; `--background` remains the clearest explicit form. Verify the printed URL returns HTTP 200, keep it alive for the whole review, and stop it explicitly with `npx hyperframes preview --stop` afterward. Use the printed URL as-is: HyperFrames URL-encodes project names that contain route metacharacters. ### Agent context from Studio selection From 50913d8f63daa9077e9bdf6365f7e76a27d65cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 15 Aug 2026 04:03:17 +0000 Subject: [PATCH 15/16] fix(cli): close preview lifecycle ownership gaps --- packages/cli/src/commands/preview.test.ts | 123 ++++++++ packages/cli/src/commands/preview.ts | 268 ++++++++++++------ .../cli/src/commands/previewLifecycle.test.ts | 110 ++++++- packages/cli/src/commands/previewLifecycle.ts | 107 ++++++- packages/cli/src/server/portUtils.ts | 46 ++- packages/cli/src/utils/orphanCleanup.test.ts | 27 ++ packages/cli/src/utils/orphanCleanup.ts | 95 +++++++ 7 files changed, 645 insertions(+), 131 deletions(-) diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts index 47b02c3625..a9586179de 100644 --- a/packages/cli/src/commands/preview.test.ts +++ b/packages/cli/src/commands/preview.test.ts @@ -6,9 +6,15 @@ import { runCommand } from "citty"; import { default as previewCommand, foregroundPreviewReadyPayload, + handlePreviewKillAll, + handlePreviewList, previewLaunchMode, previewLaunchModeError, + previewPortError, + publicPreviewPid, previewViteArgs, + reportPreviewShutdown, + studioReadyUrl, studioDeepLink, studioLandingSearch, studioSummaryUrls, @@ -20,6 +26,7 @@ const tempDirs: string[] = []; afterEach(() => { for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); + process.exitCode = undefined; }); function projectWith(storyboard: string | null, frameFiles: string[] = []): string { @@ -178,14 +185,122 @@ describe("previewLaunchMode", () => { killAll: false, }), ).toBeNull(); + expect( + previewLaunchModeError({ + background: true, + foreground: false, + status: true, + stop: false, + list: false, + killAll: false, + }), + ).toBe("Preview launch overrides cannot be combined with lifecycle actions"); + expect( + previewLaunchModeError({ + background: false, + foreground: true, + status: false, + stop: false, + list: false, + killAll: true, + }), + ).toBe("Preview launch overrides cannot be combined with lifecycle actions"); + expect( + previewLaunchModeError({ + background: false, + foreground: false, + forceNew: true, + status: true, + stop: false, + list: false, + killAll: false, + }), + ).toBe("Preview launch overrides cannot be combined with lifecycle actions"); + }); + + it.each([ + [undefined, null], + ["3002", null], + ["1", null], + ["65535", null], + ["banana", "--port must be an integer between 1 and 65535"], + ["3002oops", "--port must be an integer between 1 and 65535"], + ["0", "--port must be an integer between 1 and 65535"], + ["65536", "--port must be an integer between 1 and 65535"], + ])("validates preview port %j", (value, expected) => { + expect(previewPortError(value)).toBe(expected); + }); + + it("prefers the live server PID over its launcher PID", () => { + expect(publicPreviewPid("9876", 4321)).toBe(9876); + expect(publicPreviewPid(null, 4321)).toBe(4321); }); it("pins detached Vite to the port the lifecycle scanner waits on", () => { expect(previewViteArgs(3032)).toEqual(["--host", "127.0.0.1", "--port", "3032"]); }); + + it.each([ + [" Local: http://localhost:43127/", "http://localhost:43127"], + [" Local: http://127.0.0.1:43127/", "http://127.0.0.1:43127"], + [ + "\u001b[32m Local:\u001b[0m \u001b[36mhttp://127.0.0.1:43127/\u001b[0m", + "http://127.0.0.1:43127", + ], + ])("extracts the ready URL from Vite output %j", (output, expected) => { + expect(studioReadyUrl(output)).toBe(expected); + }); }); describe("preview lifecycle JSON failures", () => { + it.each([ + [ + "list", + () => + handlePreviewList(3002, true, { + scan: async () => { + throw new Error("list probe failed"); + }, + listManaged: async () => [], + }), + "preview-list-failed", + ], + [ + "kill-all", + () => + handlePreviewKillAll(3002, true, { + listManaged: async () => [ + { + pid: 4321, + port: 41402, + projectDir: "/tmp/managed-preview", + logPath: "/tmp/managed-preview.log", + }, + ], + stopManaged: async () => { + throw new Error("ownership failed"); + }, + killScanned: async () => 0, + }), + "preview-kill-all-failed", + ], + ] as const)("wraps %s failures in one JSON document", async (operation, run, code) => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + await run(); + + expect(log).toHaveBeenCalledOnce(); + const [line] = log.mock.calls[0] as [string]; + expect(JSON.parse(line)).toMatchObject({ + schemaVersion: 1, + operation, + ok: false, + error: { code }, + }); + expect(error).not.toHaveBeenCalled(); + }); + it("wraps managed-start validation failures in one JSON document", async () => { const dir = projectWith(null); writeFileSync(join(dir, "index.html"), ""); @@ -264,6 +379,14 @@ describe("foreground preview JSON", () => { }, }); }); + + it("keeps embedded shutdown silent after the readiness envelope", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + reportPreviewShutdown(true); + + expect(log).not.toHaveBeenCalled(); + }); }); describe("waitForStudioChildClose", () => { diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 43aa1ba51b..d5ee082735 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -56,6 +56,7 @@ import { import { lintProject } from "../utils/lintProject.js"; import { formatLintFindings } from "../utils/lintFormat.js"; import { + activeServerOnPort, findPortAndServe, scanActiveServers, killActiveServers, @@ -242,6 +243,7 @@ export default defineCommand({ const launchModeError = previewLaunchModeError({ background: Boolean(args.background), foreground: Boolean(args.foreground), + forceNew: Boolean(args["force-new"]), status: Boolean(args.status), stop: Boolean(args.stop), list: Boolean(args.list), @@ -271,6 +273,25 @@ export default defineCommand({ return; } + const portError = previewPortError(args.port); + if (portError) { + reportPreviewFailure( + Boolean(args.json), + args.status + ? "status" + : args.stop + ? "stop" + : args.list + ? "list" + : args["kill-all"] + ? "kill-all" + : "start", + "preview-validation-failed", + portError, + ); + return; + } + const browserGpuMode = resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined); if (args["browser-gpu"] === true) process.env.PRODUCER_BROWSER_GPU_MODE = "hardware"; if (args["browser-gpu"] === false) process.env.PRODUCER_BROWSER_GPU_MODE = "software"; @@ -348,86 +369,13 @@ export default defineCommand({ // --list: scan and display active servers if (args.list) { - const [scannedServers, managedSessions] = await Promise.all([ - scanActiveServers(startPort), - listBackgroundPreviewStatuses(), - ]); - const managedKeys = new Set( - managedSessions.map((session) => `${resolve(session.projectDir)}\0${session.port}`), - ); - const servers = [ - ...managedSessions.map((session) => ({ - port: session.port, - host: "127.0.0.1", - projectName: basename(session.projectDir), - projectDir: session.projectDir, - version: "managed", - pid: String(session.pid), - })), - ...scannedServers.filter( - (server) => !managedKeys.has(`${resolve(server.projectDir)}\0${server.port}`), - ), - ]; - if (args.json) { - writeLifecycleJson( - lifecyclePayload("list", { - state: "listed", - sessions: servers.map((server) => - previewLifecycleSession({ - state: "running", - mode: "unknown", - projectName: server.projectName, - projectDir: server.projectDir, - port: server.port, - pid: server.pid ? Number(server.pid) : null, - host: server.host, - }), - ), - }), - ); - return; - } - if (servers.length === 0) { - console.log("\n No active preview servers found.\n"); - return; - } - console.log(`\n ${c.bold("Active preview servers:")}\n`); - for (const s of servers) { - const pidStr = s.pid ? c.dim(` (PID ${s.pid})`) : ""; - console.log( - ` ${c.accent(`Port ${s.port}`)} ${s.projectName} ${c.dim(s.projectDir)}${pidStr}`, - ); - } - console.log(`\n ${servers.length} server${servers.length === 1 ? "" : "s"} running.\n`); + await handlePreviewList(startPort, Boolean(args.json)); return; } // --kill-all: kill all active servers if (args["kill-all"]) { - const managedSessions = await listBackgroundPreviewStatuses(); - let killed = 0; - for (const session of managedSessions) { - if (await stopBackgroundPreview(session.projectDir, session.port)) killed++; - } - killed += await killActiveServers(startPort); - if (killed === 0) { - if (args.json) { - writeLifecycleJson(lifecyclePayload("kill-all", { state: "killed-all", stopped: 0 })); - } else { - console.log("\n No active preview servers to kill.\n"); - } - return; - } - if (args.json) { - writeLifecycleJson( - lifecyclePayload("kill-all", { - state: "killed-all", - stopped: killed, - }), - ); - } else { - console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.\n`); - } + await handlePreviewKillAll(startPort, Boolean(args.json)); return; } @@ -664,6 +612,7 @@ export function previewLaunchMode(options: { export function previewLaunchModeError(options: { background: boolean; foreground: boolean; + forceNew?: boolean; status: boolean; stop: boolean; list: boolean; @@ -675,9 +624,28 @@ export function previewLaunchModeError(options: { const actionCount = [options.status, options.stop, options.list, options.killAll].filter( Boolean, ).length; - return actionCount > 1 - ? "Only one of --status, --stop, --list, or --kill-all can be used at a time" - : null; + if (actionCount > 1) { + return "Only one of --status, --stop, --list, or --kill-all can be used at a time"; + } + if (actionCount > 0 && (options.background || options.foreground || options.forceNew)) { + return "Preview launch overrides cannot be combined with lifecycle actions"; + } + return null; +} + +export function previewPortError(port: string | undefined): string | null { + const value = port ?? "3002"; + if (!/^\d+$/.test(value)) return "--port must be an integer between 1 and 65535"; + const parsed = Number(value); + return parsed >= 1 && parsed <= 65535 ? null : "--port must be an integer between 1 and 65535"; +} + +export function publicPreviewPid( + serverPid: string | null | undefined, + fallbackPid: number | null, +): number | null { + const parsed = Number(serverPid); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallbackPid; } function reportPreviewFailure( @@ -691,6 +659,103 @@ function reportPreviewFailure( setCommandExitCode(1); } +interface PreviewActionDependencies { + scan?: typeof scanActiveServers; + listManaged?: typeof listBackgroundPreviewStatuses; + stopManaged?: typeof stopBackgroundPreview; + killScanned?: typeof killActiveServers; +} + +export async function handlePreviewList( + startPort: number, + json: boolean, + dependencies: PreviewActionDependencies = {}, +): Promise { + try { + const [scannedServers, managedSessions] = await Promise.all([ + (dependencies.scan ?? scanActiveServers)(startPort), + (dependencies.listManaged ?? listBackgroundPreviewStatuses)(), + ]); + const managedKeys = new Set( + managedSessions.map((session) => `${resolve(session.projectDir)}\0${session.port}`), + ); + const servers = [ + ...managedSessions.map((session) => ({ + port: session.port, + host: "127.0.0.1", + projectName: basename(session.projectDir), + projectDir: session.projectDir, + version: "managed", + pid: String(session.pid), + })), + ...scannedServers.filter( + (server) => !managedKeys.has(`${resolve(server.projectDir)}\0${server.port}`), + ), + ]; + if (json) { + writeLifecycleJson( + lifecyclePayload("list", { + state: "listed", + sessions: servers.map((server) => + previewLifecycleSession({ + state: "running", + mode: server.version === "managed" ? "background" : "unknown", + projectName: server.projectName, + projectDir: server.projectDir, + port: server.port, + pid: server.pid ? Number(server.pid) : null, + host: server.host, + }), + ), + }), + ); + return; + } + if (servers.length === 0) { + console.log("\n No active preview servers found.\n"); + return; + } + console.log(`\n ${c.bold("Active preview servers:")}\n`); + for (const server of servers) { + const pid = server.pid ? c.dim(` (PID ${server.pid})`) : ""; + console.log( + ` ${c.accent(`Port ${server.port}`)} ${server.projectName} ${c.dim(server.projectDir)}${pid}`, + ); + } + console.log(`\n ${servers.length} server${servers.length === 1 ? "" : "s"} running.\n`); + } catch (error) { + reportPreviewFailure(json, "list", "preview-list-failed", errorMessage(error)); + } +} + +export async function handlePreviewKillAll( + startPort: number, + json: boolean, + dependencies: PreviewActionDependencies = {}, +): Promise { + try { + const managedSessions = await (dependencies.listManaged ?? listBackgroundPreviewStatuses)(); + let killed = 0; + for (const session of managedSessions) { + if ( + await (dependencies.stopManaged ?? stopBackgroundPreview)(session.projectDir, session.port) + ) { + killed++; + } + } + killed += await (dependencies.killScanned ?? killActiveServers)(startPort); + if (json) { + writeLifecycleJson(lifecyclePayload("kill-all", { state: "killed-all", stopped: killed })); + } else if (killed === 0) { + console.log("\n No active preview servers to kill.\n"); + } else { + console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.\n`); + } + } catch (error) { + reportPreviewFailure(json, "kill-all", "preview-kill-all-failed", errorMessage(error)); + } +} + export function previewViteArgs(port: number | undefined): string[] { return ["--host", "127.0.0.1", ...(port === undefined ? [] : ["--port", String(port)])]; } @@ -1267,14 +1332,21 @@ function attachStudioReadyHandler( ): void { let detected = false; - function handleOutput(data: Buffer): void { - const url = data.toString().match(/Local:\s+(http:\/\/localhost:\d+)/)?.[1]; + async function handleOutput(data: Buffer): Promise { + const url = studioReadyUrl(data.toString()); if (!url || detected) return; detected = true; if (options?.json) { + const port = Number(new URL(url).port); + const server = await activeServerOnPort(port); writeLifecycleJson( - foregroundPreviewReadyPayload(projectName, url, projectDir, child.pid ?? null), + foregroundPreviewReadyPayload( + projectName, + url, + projectDir, + publicPreviewPid(server?.pid, child.pid ?? null), + ), ); } else { spinner.stop(c.success("Studio running")); @@ -1287,8 +1359,8 @@ function attachStudioReadyHandler( child.stderr.removeListener("data", handleOutput); } - child.stdout.on("data", handleOutput); - child.stderr.on("data", handleOutput); + child.stdout.on("data", (data) => void handleOutput(data)); + child.stderr.on("data", (data) => void handleOutput(data)); child.on("error", (err) => { if (options?.json) { reportPreviewFailure(true, "start", "preview-start-failed", err.message); @@ -1299,6 +1371,17 @@ function attachStudioReadyHandler( }); } +export function studioReadyUrl(output: string): string | null { + const localLine = output.split(/\r?\n/).find((line) => line.includes("Local:")); + return localLine?.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):\d+/)?.[0] ?? null; +} + +export function reportPreviewShutdown(json: boolean): void { + if (json) return; + console.log(); + console.log(` ${c.dim("Shutting down studio...")}`); +} + /** * Dev mode: spawn the studio dev server from the monorepo. */ @@ -1429,14 +1512,15 @@ async function runEmbeddedMode( return; } - const { app } = createStudioServer({ + // Compute everything that may throw before acquiring the fs.watch handle. + // Once createStudioServer returns, every subsequent exit path must close it. + const serverBuildSignature = await loadPreviewServerBuildSignature(); + const { app, watcher } = createStudioServer({ projectDir: dir, projectName: pName, autoProxy: options?.autoProxy, browserGpuMode: options?.browserGpuMode, }); - const serverBuildSignature = await loadPreviewServerBuildSignature(); - let result: FindPortResult; try { result = await findPortAndServe( @@ -1449,6 +1533,7 @@ async function runEmbeddedMode( options?.browserGpuMode, ); } catch (err: unknown) { + watcher.close(); reportPreviewFailure( Boolean(options?.json), "start", @@ -1459,8 +1544,13 @@ async function runEmbeddedMode( } if (result.type === "already-running") { + // createStudioServer acquires an fs.watch handle before port discovery. + // Reuse owns no local server, so release that handle before returning or + // the otherwise-finished CLI process remains alive indefinitely. + watcher.close(); const url = `http://localhost:${result.port}`; if (options?.json) { + const server = await activeServerOnPort(result.port); writeLifecycleJson( lifecyclePayload( "start", @@ -1470,7 +1560,7 @@ async function runEmbeddedMode( projectName: pName, projectDir: dir, port: result.port, - pid: null, + pid: publicPreviewPid(server?.pid, null), }), ), ); @@ -1534,8 +1624,7 @@ async function runEmbeddedMode( process.off("SIGINT", shutdown); process.off("SIGTERM", shutdown); rl?.close(); - console.log(); - console.log(` ${c.dim("Shutting down studio...")}`); + reportPreviewShutdown(Boolean(options?.json)); // Hard deadline: if cleanup hangs (e.g. dead Chrome never responds to // browser.close()), force exit. Armed before awaiting cleanup so it @@ -1554,6 +1643,7 @@ async function runEmbeddedMode( cleanup() .catch(() => {}) .finally(() => { + watcher.close(); result.server.close(() => resolveRun()); }); }; diff --git a/packages/cli/src/commands/previewLifecycle.test.ts b/packages/cli/src/commands/previewLifecycle.test.ts index 132a256f49..50d455d3e4 100644 --- a/packages/cli/src/commands/previewLifecycle.test.ts +++ b/packages/cli/src/commands/previewLifecycle.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; @@ -167,6 +167,50 @@ describe("background preview lifecycle", () => { expect(spawn).toHaveBeenCalledOnce(); }); + it("force-new replaces a previously managed server instead of orphaning it", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + const oldServer = { ...server, port: 41490, browserGpuMode: "hardware" as const }; + writePreviewSession( + { pid: 4321, port: 41490, projectDir, logPath: "/tmp/preview.log" }, + stateHome, + ); + const replacement = { + ...server, + port: 41491, + pid: "5432", + browserGpuMode: "software" as const, + }; + let oldRunning = true; + let replacementRunning = false; + const scan = vi.fn(async () => + oldRunning ? [oldServer] : replacementRunning ? [replacement] : [], + ); + const kill = vi.fn((pid: number) => { + if (pid === 4321) oldRunning = false; + }); + const spawn = vi.fn(() => { + replacementRunning = true; + return { pid: 5432, unref: vi.fn() }; + }); + + const result = await startBackgroundPreview(projectDir, 41491, { + browserGpuMode: "software", + forceNew: true, + kill, + scan, + sleep: async () => {}, + spawn, + stateHome, + }); + + expect(scan).toHaveBeenNthCalledWith(1, 41490); + expect(kill).toHaveBeenCalledWith(4321); + expect(result).toMatchObject({ type: "started", port: 41491, pid: 5432 }); + expect(readFileSync(previewSessionPath(projectDir, stateHome), "utf8")).toContain( + '"port": 41491', + ); + }); + it("starts a replacement when the existing server uses a different GPU policy", async () => { const hardwareServer = { ...server, browserGpuMode: "hardware" as const }; const softwareServer = { @@ -214,6 +258,24 @@ describe("background preview lifecycle", () => { expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(true); }); + it("reports the live server PID while retaining the wrapper PID for cleanup", async () => { + const liveServer = { ...server, pid: "9876" }; + let scans = 0; + const scan = vi.fn(async () => (++scans === 1 ? [] : [liveServer])); + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + + const result = await startBackgroundPreview(projectDir, 3002, { + scan, + spawn: () => ({ pid: 4321, unref: vi.fn() }), + stateHome, + }); + + expect(result).toMatchObject({ type: "started", pid: 9876 }); + expect( + JSON.parse(readFileSync(previewSessionPath(projectDir, stateHome), "utf8")), + ).toMatchObject({ pid: 4321 }); + }); + it("reaps a detached child that never becomes reachable without recording ownership", async () => { const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); const kill = vi.fn(); @@ -320,9 +382,49 @@ describe("background preview lifecycle", () => { expect(kill).not.toHaveBeenCalled(); }); - it("does not kill an unproven saved wrapper PID when the live server reports another PID", async () => { + it("reaps the saved wrapper when the live server is proven to be its descendant", async () => { const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); - savePreviewSession(stateHome); + writePreviewSession( + { + pid: 4321, + wrapperIdentity: "wrapper-birth", + port: 3210, + projectDir, + logPath: "/tmp/preview.log", + }, + stateHome, + ); + let running = true; + const scan = vi.fn(async () => (running ? [{ ...server, pid: "9876" }] : [])); + const kill = vi.fn((pid: number) => { + if (pid === 4321) running = false; + }); + + const result = await stopBackgroundPreview(projectDir, 3002, { + scan, + kill, + isDescendant: (childPid, ancestorPid) => childPid === 9876 && ancestorPid === 4321, + identity: (pid) => (pid === 4321 ? "wrapper-birth" : null), + sleep: async () => {}, + stateHome, + }); + + expect(result).toBe(true); + expect(kill.mock.calls).toEqual([[4321]]); + }); + + it("kills only the live server when the saved wrapper birth identity has changed", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + writePreviewSession( + { + pid: 4321, + wrapperIdentity: "original-wrapper-birth", + port: 3210, + projectDir, + logPath: "/tmp/preview.log", + }, + stateHome, + ); let running = true; const scan = vi.fn(async () => (running ? [{ ...server, pid: "9876" }] : [])); const kill = vi.fn((pid: number) => { @@ -332,6 +434,8 @@ describe("background preview lifecycle", () => { const result = await stopBackgroundPreview(projectDir, 3002, { scan, kill, + isDescendant: () => true, + identity: () => "reused-pid-birth", sleep: async () => {}, stateHome, }); diff --git a/packages/cli/src/commands/previewLifecycle.ts b/packages/cli/src/commands/previewLifecycle.ts index 80bdcda4ab..8067524e9b 100644 --- a/packages/cli/src/commands/previewLifecycle.ts +++ b/packages/cli/src/commands/previewLifecycle.ts @@ -14,10 +14,11 @@ import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { scanActiveServers, type ActiveServer } from "../server/portUtils.js"; import type { BrowserGpuMode } from "../browser/gpuPolicy.js"; -import { killProcessTree } from "../utils/orphanCleanup.js"; +import { isProcessDescendant, killProcessTree, processIdentity } from "../utils/orphanCleanup.js"; export interface PreviewSession { pid: number; + wrapperIdentity?: string; port: number; projectDir: string; logPath: string; @@ -41,6 +42,8 @@ interface LifecycleDependencies { spawn?: SpawnPreview; sleep?: (ms: number) => Promise; kill?: (pid: number) => void; + isDescendant?: (childPid: number, ancestorPid: number) => boolean; + identity?: (pid: number) => string | null; stateHome?: string; forceNew?: boolean; browserGpuMode?: BrowserGpuMode; @@ -168,7 +171,7 @@ function spawnDetachedPreview( projectDir: string, stateHome: string, dependencies: LifecycleDependencies, -): { pid: number; logPath: string } { +): { pid: number; wrapperIdentity: string | undefined; logPath: string } { const logPath = previewLogPath(projectDir, stateHome); mkdirSync(dirname(logPath), { recursive: true }); const logFd = openSync(logPath, "a", 0o600); @@ -189,7 +192,11 @@ function spawnDetachedPreview( } if (!child.pid) throw new Error("background preview child did not report a PID"); child.unref(); - return { pid: child.pid, logPath }; + return { + pid: child.pid, + wrapperIdentity: (dependencies.identity ?? processIdentity)(child.pid) ?? undefined, + logPath, + }; } function startedServer( @@ -271,6 +278,68 @@ export async function listBackgroundPreviewStatuses( return statuses.filter((status): status is PreviewSession => status !== null); } +function readyPreviewSession( + server: ActiveServer, + pid: number, + wrapperIdentity: string | undefined, + projectDir: string, + logPath: string, + dependencies: LifecycleDependencies, +): { session: PreviewSession; publicPid: number } { + const identity = wrapperIdentity ?? (dependencies.identity ?? processIdentity)(pid) ?? undefined; + const liveServerPid = Number(server.pid); + return { + session: { + pid, + wrapperIdentity: identity, + port: server.port, + projectDir: resolve(projectDir), + logPath, + }, + publicPid: Number.isInteger(liveServerPid) && liveServerPid > 0 ? liveServerPid : pid, + }; +} + +function ownedStopTargetPid( + saved: PreviewSession | null, + liveServerPid: number, + dependencies: LifecycleDependencies, +): number { + if (!saved?.wrapperIdentity) return liveServerPid; + const identity = dependencies.identity ?? processIdentity; + if (identity(saved.pid) !== saved.wrapperIdentity) return liveServerPid; + if (saved.pid === liveServerPid) return saved.pid; + const isDescendant = dependencies.isDescendant ?? isProcessDescendant; + return isDescendant(liveServerPid, saved.pid) ? saved.pid : liveServerPid; +} + +async function replaceOwnedPreviewForForceNew( + existing: ActiveServer | null, + saved: PreviewSession | null, + projectDir: string, + dependencies: LifecycleDependencies, +): Promise { + if (!dependencies.forceNew || !existing || saved?.port !== existing.port) return existing; + const stopped = await stopBackgroundPreview(projectDir, saved.port, dependencies); + if (!stopped) throw new Error(`managed preview could not be replaced for ${resolve(projectDir)}`); + return null; +} + +function existingPreviewForLaunch( + servers: ActiveServer[], + saved: PreviewSession | null, + projectDir: string, + dependencies: LifecycleDependencies, +): ActiveServer | null { + const requested = matchingServer(servers, projectDir, dependencies.browserGpuMode); + if (!dependencies.forceNew || !saved) return requested; + // Ownership comes from the saved project+port, not the replacement's GPU + // policy. Filtering here would miss an owned hardware→software replacement + // and overwrite the only session record while leaving the old listener live. + const savedPortServers = servers.filter((server) => server.port === saved.port); + return matchingServer(savedPortServers, projectDir) ?? requested; +} + export async function startBackgroundPreview( projectDir: string, startPort: number, @@ -282,8 +351,11 @@ export async function startBackgroundPreview( const scan = dependencies.scan ?? scanActiveServers; const stateHome = dependencies.stateHome ?? defaultStateHome(); const saved = readPreviewSession(projectDir, stateHome); - const scanStart = dependencies.forceNew ? startPort : (saved?.port ?? startPort); - const existing = matchingServer(await scan(scanStart), projectDir, dependencies.browserGpuMode); + // Always inspect a saved custom port first. `--force-new --port ` must + // replace that owned server before recording the replacement, otherwise the + // single per-project ownership record would orphan the old listener. + const scanStart = saved?.port ?? startPort; + let existing = existingPreviewForLaunch(await scan(scanStart), saved, projectDir, dependencies); if (existing && !dependencies.forceNew) { return { type: "reused", @@ -292,8 +364,13 @@ export async function startBackgroundPreview( logPath: null, }; } + existing = await replaceOwnedPreviewForForceNew(existing, saved, projectDir, dependencies); - const { pid, logPath } = spawnDetachedPreview(projectDir, stateHome, dependencies); + const { pid, wrapperIdentity, logPath } = spawnDetachedPreview( + projectDir, + stateHome, + dependencies, + ); const sleep = dependencies.sleep ?? delay; for (let attempt = 0; attempt < 50; attempt++) { @@ -305,14 +382,20 @@ export async function startBackgroundPreview( dependencies.browserGpuMode, ); if (server) { - const session = { + const ready = readyPreviewSession( + server, pid, - port: server.port, - projectDir: resolve(projectDir), + wrapperIdentity, + projectDir, logPath, + dependencies, + ); + writePreviewSession(ready.session, stateHome); + return { + type: "started", + ...ready.session, + pid: ready.publicPid, }; - writePreviewSession(session, stateHome); - return { type: "started", ...session }; } await sleep(200); } @@ -344,7 +427,7 @@ export async function stopBackgroundPreview( } const kill = dependencies.kill ?? stopProcess; - kill(pid); + kill(ownedStopTargetPid(saved, pid, dependencies)); const sleep = dependencies.sleep ?? delay; for (let attempt = 0; attempt < 25; attempt++) { diff --git a/packages/cli/src/server/portUtils.ts b/packages/cli/src/server/portUtils.ts index ee9cb6f4a9..f62b6bb0eb 100644 --- a/packages/cli/src/server/portUtils.ts +++ b/packages/cli/src/server/portUtils.ts @@ -15,7 +15,6 @@ import http from "node:http"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { resolve } from "node:path"; -import { c } from "../ui/colors.js"; import type { BrowserGpuMode } from "../browser/gpuPolicy.js"; const execFileAsync = promisify(execFile); @@ -285,24 +284,7 @@ export async function scanActiveServers(startPort = 3002): Promise batchStart + i); - const results = await Promise.all( - ports.map(async (port) => { - const config = await probePort(port); - if (!config) return null; - const pid = - Number.isInteger(config.pid) && Number(config.pid) > 0 - ? String(config.pid) - : await getProcessOnPort(port); - return { - port, - projectName: config.projectName, - projectDir: config.projectDir, - version: config.version, - pid, - browserGpuMode: config.browserGpuMode, - }; - }), - ); + const results = await Promise.all(ports.map((port) => activeServerOnPort(port))); for (const r of results) { if (r) servers.push(r); @@ -312,6 +294,24 @@ export async function scanActiveServers(startPort = 3002): Promise { + const config = await probePort(port); + if (!config) return null; + const pid = + Number.isInteger(config.pid) && Number(config.pid) > 0 + ? String(config.pid) + : await getProcessOnPort(port); + return { + port, + projectName: config.projectName, + projectDir: config.projectDir, + version: config.version, + pid, + browserGpuMode: config.browserGpuMode, + }; +} + /** * Kill all active HyperFrames preview servers by sending SIGTERM to their PIDs. * Returns the number of servers killed. @@ -416,17 +416,9 @@ export async function findPortAndServe( return { type: "already-running", port }; } if (detection.type === "mismatch") { - console.log( - ` ${c.dim(`Port ${port} in use by HyperFrames project "${detection.projectName}" — skipping`)}`, - ); continue; } } - - const pid = await getProcessOnPort(port); - if (pid) { - console.log(` ${c.dim(`Port ${port} in use by PID ${pid} — skipping`)}`); - } } throw new Error( diff --git a/packages/cli/src/utils/orphanCleanup.test.ts b/packages/cli/src/utils/orphanCleanup.test.ts index d599bad2f5..aebe4d0b6c 100644 --- a/packages/cli/src/utils/orphanCleanup.test.ts +++ b/packages/cli/src/utils/orphanCleanup.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from "vitest"; import { spawn } from "node:child_process"; import { + isProcessDescendant, killProcessTree, killOrphanedProcesses, + processIdentity, windowsProcessTreeKillArgs, } from "./orphanCleanup.js"; @@ -14,6 +16,31 @@ describe("Windows process-tree cleanup", () => { }); }); +describe("process-tree ownership", () => { + it("captures a stable birth token for the current process", () => { + const first = processIdentity(process.pid); + expect(first).toMatch(/^(?:linux|posix|windows):/); + expect(processIdentity(process.pid)).toBe(first); + expect(processIdentity(-1)).toBeNull(); + }); + + it("proves ancestry through every intermediate wrapper", () => { + const parents = new Map([ + [400, 300], + [300, 200], + [200, 1], + ]); + + expect(isProcessDescendant(400, 200, (pid) => parents.get(pid) ?? null)).toBe(true); + expect(isProcessDescendant(400, 999, (pid) => parents.get(pid) ?? null)).toBe(false); + }); + + it("fails closed on missing or cyclic process metadata", () => { + expect(isProcessDescendant(400, 200, () => null)).toBe(false); + expect(isProcessDescendant(400, 200, (pid) => (pid === 400 ? 300 : 400))).toBe(false); + }); +}); + describe.skipIf(!IS_UNIX)("killProcessTree", () => { it("kills a process and all its children", async () => { // Spawn a parent that spawns two sleeping children diff --git a/packages/cli/src/utils/orphanCleanup.ts b/packages/cli/src/utils/orphanCleanup.ts index bbd1458aad..ae630c0151 100644 --- a/packages/cli/src/utils/orphanCleanup.ts +++ b/packages/cli/src/utils/orphanCleanup.ts @@ -1,4 +1,5 @@ import { execFileSync, execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; /** * Find and kill orphaned Chrome processes from previous crashed sessions. @@ -78,6 +79,100 @@ export function windowsProcessTreeKillArgs(pid: number): string[] { return ["/PID", String(pid), "/T", "/F"]; } +/** + * Return a process birth token suitable for detecting PID reuse. The token is + * diagnostic state only: callers must still prove the live server is a + * descendant before treating a saved wrapper as the owned process-tree root. + */ +export function processIdentity(pid: number): string | null { + if (!Number.isInteger(pid) || pid <= 0) return null; + try { + if (process.platform === "win32") { + const created = execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CreationDate.ToFileTimeUtc()`, + ], + { encoding: "utf8", timeout: 2000 }, + ).trim(); + return created ? `windows:${created}` : null; + } + + if (process.platform === "linux") { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const fields = stat + .slice(stat.lastIndexOf(") ") + 2) + .trim() + .split(/\s+/); + const startTicks = fields[19]; // field 22 overall; fields starts at process state (3) + return startTicks ? `linux:${startTicks}` : null; + } + + const started = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { + encoding: "utf8", + timeout: 2000, + }).trim(); + return started ? `posix:${started}` : null; + } catch { + return null; + } +} + +type ParentPidLookup = (pid: number) => number | null; + +function processParentPid(pid: number): number | null { + try { + const output = + process.platform === "win32" + ? execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').ParentProcessId`, + ], + { encoding: "utf8", timeout: 2000 }, + ) + : execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], { + encoding: "utf8", + timeout: 2000, + }); + const parentPid = Number(output.trim()); + return Number.isInteger(parentPid) && parentPid > 0 ? parentPid : null; + } catch { + return null; + } +} + +/** + * Prove that `childPid` currently belongs to the process tree rooted at + * `ancestorPid`. The walk fails closed on missing, invalid, or cyclic process + * metadata so a stale saved PID can never authorize terminating a new process. + */ +export function isProcessDescendant( + childPid: number, + ancestorPid: number, + parentPid: ParentPidLookup = processParentPid, +): boolean { + if (childPid <= 0 || ancestorPid <= 0 || childPid === ancestorPid) return false; + + const visited = new Set(); + let current = childPid; + for (let depth = 0; depth < 64; depth++) { + if (visited.has(current)) return false; + visited.add(current); + const parent = parentPid(current); + if (parent === ancestorPid) return true; + if (parent === null || parent <= 1) return false; + current = parent; + } + return false; +} + function getDescendants(pid: number): number[] { let children: number[]; try { From 7368231e5b0a2712f35c95b40589fda15f87975a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 15 Aug 2026 04:21:02 +0000 Subject: [PATCH 16/16] fix(cli): preserve managed preview ownership --- packages/cli/src/commands/preview.ts | 5 +- .../cli/src/commands/previewLifecycle.test.ts | 152 ++++++++++++++---- packages/cli/src/commands/previewLifecycle.ts | 79 ++++++--- 3 files changed, 179 insertions(+), 57 deletions(-) diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index d5ee082735..822eb99a65 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -497,7 +497,10 @@ export default defineCommand({ try { background = await startBackgroundPreview(dir, startPort, { forceNew: Boolean(args["force-new"]), - browserGpuMode, + // A bare launch promises same-project reuse, regardless of the mode + // the existing managed server resolved earlier. Only an explicit + // --browser-gpu/--no-browser-gpu request authorizes replacement. + browserGpuMode: args["browser-gpu"] === undefined ? undefined : browserGpuMode, }); } catch (error) { const message = errorMessage(error); diff --git a/packages/cli/src/commands/previewLifecycle.test.ts b/packages/cli/src/commands/previewLifecycle.test.ts index 50d455d3e4..a3a8f4b3a2 100644 --- a/packages/cli/src/commands/previewLifecycle.test.ts +++ b/packages/cli/src/commands/previewLifecycle.test.ts @@ -84,7 +84,7 @@ describe("background preview lifecycle", () => { { pid: 4321, port: 41402, projectDir, logPath: "/tmp/custom.log" }, stateHome, ); - const customServer = { ...server, port: 41402 }; + const customServer = { ...server, port: 41402, browserGpuMode: "software" as const }; const scan = vi.fn(async (startPort?: number) => (startPort === 41402 ? [customServer] : [])); const spawn = vi.fn(); @@ -167,35 +167,92 @@ describe("background preview lifecycle", () => { expect(spawn).toHaveBeenCalledOnce(); }); - it("force-new replaces a previously managed server instead of orphaning it", async () => { + it.each([ + ["force-new", true], + ["a GPU-policy change", false], + ])( + "%s replaces a previously managed server instead of orphaning it", + async (_label, forceNew) => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + const oldServer = { ...server, port: 41490, browserGpuMode: "hardware" as const }; + writePreviewSession( + { pid: 4321, port: 41490, projectDir, logPath: "/tmp/preview.log" }, + stateHome, + ); + const replacement = { + ...server, + port: 41491, + pid: "5432", + browserGpuMode: "software" as const, + }; + let oldRunning = true; + let replacementRunning = false; + const scan = vi.fn(async () => + oldRunning ? [oldServer] : replacementRunning ? [replacement] : [], + ); + const kill = vi.fn((pid: number) => { + if (pid === 4321) oldRunning = false; + }); + const spawn = vi.fn(() => { + replacementRunning = true; + return { pid: 5432, unref: vi.fn() }; + }); + + const result = await startBackgroundPreview(projectDir, 41491, { + browserGpuMode: "software", + forceNew, + kill, + scan, + sleep: async () => {}, + spawn, + stateHome, + }); + + expect(scan).toHaveBeenNthCalledWith(1, 41490); + expect(kill).toHaveBeenCalledWith(4321); + expect(result).toMatchObject({ type: "started", port: 41491, pid: 5432 }); + expect(readFileSync(previewSessionPath(projectDir, stateHome), "utf8")).toContain( + '"port": 41491', + ); + }, + ); + + it("replaces the owned preview instead of reusing an unmanaged policy-matching sibling", async () => { const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); - const oldServer = { ...server, port: 41490, browserGpuMode: "hardware" as const }; - writePreviewSession( - { pid: 4321, port: 41490, projectDir, logPath: "/tmp/preview.log" }, - stateHome, - ); - const replacement = { + const owned = { ...server, port: 41490, browserGpuMode: "hardware" as const }; + const sibling = { ...server, port: 41491, + pid: "8765", + browserGpuMode: "software" as const, + }; + const replacement = { + ...server, + port: 41492, pid: "5432", browserGpuMode: "software" as const, }; - let oldRunning = true; - let replacementRunning = false; - const scan = vi.fn(async () => - oldRunning ? [oldServer] : replacementRunning ? [replacement] : [], + writePreviewSession( + { pid: 4321, port: owned.port, projectDir, logPath: "/tmp/preview.log" }, + stateHome, ); + let ownedRunning = true; + let replacementRunning = false; + const scan = vi.fn(async () => [ + ...(ownedRunning ? [owned] : []), + sibling, + ...(replacementRunning ? [replacement] : []), + ]); const kill = vi.fn((pid: number) => { - if (pid === 4321) oldRunning = false; + if (pid === 4321) ownedRunning = false; }); const spawn = vi.fn(() => { replacementRunning = true; return { pid: 5432, unref: vi.fn() }; }); - const result = await startBackgroundPreview(projectDir, 41491, { + const result = await startBackgroundPreview(projectDir, replacement.port, { browserGpuMode: "software", - forceNew: true, kill, scan, sleep: async () => {}, @@ -203,12 +260,9 @@ describe("background preview lifecycle", () => { stateHome, }); - expect(scan).toHaveBeenNthCalledWith(1, 41490); expect(kill).toHaveBeenCalledWith(4321); - expect(result).toMatchObject({ type: "started", port: 41491, pid: 5432 }); - expect(readFileSync(previewSessionPath(projectDir, stateHome), "utf8")).toContain( - '"port": 41491', - ); + expect(spawn).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ type: "started", port: replacement.port, pid: 5432 }); }); it("starts a replacement when the existing server uses a different GPU policy", async () => { @@ -219,11 +273,15 @@ describe("background preview lifecycle", () => { pid: "5432", browserGpuMode: "software" as const, }; - let scans = 0; - const scan = vi.fn(async () => - ++scans < 2 ? [hardwareServer] : [hardwareServer, softwareServer], - ); - const spawn = vi.fn(() => ({ pid: 5432, unref: vi.fn() })); + let replacementRunning = false; + const scan = vi.fn(async () => [ + hardwareServer, + ...(replacementRunning ? [softwareServer] : []), + ]); + const spawn = vi.fn(() => { + replacementRunning = true; + return { pid: 5432, unref: vi.fn() }; + }); const result = await startBackgroundPreview(projectDir, 3002, { browserGpuMode: "software", @@ -238,10 +296,13 @@ describe("background preview lifecycle", () => { }); it("returns after a detached child becomes reachable and records its session", async () => { - let scans = 0; - const scan = vi.fn(async () => (++scans < 2 ? [] : [server])); + let spawned = false; + const scan = vi.fn(async () => (spawned ? [server] : [])); const unref = vi.fn(); - const spawn = vi.fn(() => ({ pid: 4321, unref })); + const spawn = vi.fn(() => { + spawned = true; + return { pid: 4321, unref }; + }); const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); const result = await startBackgroundPreview(projectDir, 3002, { @@ -260,13 +321,16 @@ describe("background preview lifecycle", () => { it("reports the live server PID while retaining the wrapper PID for cleanup", async () => { const liveServer = { ...server, pid: "9876" }; - let scans = 0; - const scan = vi.fn(async () => (++scans === 1 ? [] : [liveServer])); + let spawned = false; + const scan = vi.fn(async () => (spawned ? [liveServer] : [])); const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); const result = await startBackgroundPreview(projectDir, 3002, { scan, - spawn: () => ({ pid: 4321, unref: vi.fn() }), + spawn: () => { + spawned = true; + return { pid: 4321, unref: vi.fn() }; + }, stateHome, }); @@ -461,4 +525,30 @@ describe("background preview lifecycle", () => { ).rejects.toThrow(/did not stop/i); expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(true); }); + + it("verifies the owned port stopped even when another server serves the same project", async () => { + const stateHome = mkdtempSync(join(tmpdir(), "hf-preview-state-")); + const owned = { ...server, port: 41490 }; + const sibling = { ...server, port: 41491, pid: "8765" }; + writePreviewSession( + { pid: 4321, port: owned.port, projectDir, logPath: "/tmp/preview.log" }, + stateHome, + ); + let ownedRunning = true; + const scan = vi.fn(async () => [...(ownedRunning ? [owned] : []), sibling]); + const kill = vi.fn((pid: number) => { + if (pid === 4321) ownedRunning = false; + }); + + const stopped = await stopBackgroundPreview(projectDir, owned.port, { + kill, + scan, + sleep: async () => {}, + stateHome, + }); + + expect(stopped).toBe(true); + expect(kill).toHaveBeenCalledWith(4321); + expect(existsSync(previewSessionPath(projectDir, stateHome))).toBe(false); + }); }); diff --git a/packages/cli/src/commands/previewLifecycle.ts b/packages/cli/src/commands/previewLifecycle.ts index 8067524e9b..f1af298f91 100644 --- a/packages/cli/src/commands/previewLifecycle.ts +++ b/packages/cli/src/commands/previewLifecycle.ts @@ -154,6 +154,26 @@ function matchingServer( ); } +function matchingServerAtPort( + servers: ActiveServer[], + projectDir: string, + port: number, +): ActiveServer | null { + return matchingServer( + servers.filter((server) => server.port === port), + projectDir, + ); +} + +function sameProjectPorts(servers: ActiveServer[], projectDir: string): Set { + const project = normalized(projectDir); + return new Set( + servers + .filter((server) => normalized(server.projectDir) === project) + .map((server) => server.port), + ); +} + function stopProcess(pid: number): void { killProcessTree(pid); if (process.platform === "win32") { @@ -202,12 +222,10 @@ function spawnDetachedPreview( function startedServer( servers: ActiveServer[], projectDir: string, - existing: ActiveServer | null, - forceNew: boolean, + preLaunchPorts: Set, browserGpuMode?: BrowserGpuMode, ): ActiveServer | null { - const candidates = - forceNew && existing ? servers.filter((server) => server.port !== existing.port) : servers; + const candidates = servers.filter((server) => !preLaunchPorts.has(server.port)); return matchingServer(candidates, projectDir, browserGpuMode); } @@ -313,31 +331,27 @@ function ownedStopTargetPid( return isDescendant(liveServerPid, saved.pid) ? saved.pid : liveServerPid; } -async function replaceOwnedPreviewForForceNew( - existing: ActiveServer | null, - saved: PreviewSession | null, +async function stopOwnedPreviewBeforeReplacement( + owned: ActiveServer | null, projectDir: string, dependencies: LifecycleDependencies, -): Promise { - if (!dependencies.forceNew || !existing || saved?.port !== existing.port) return existing; - const stopped = await stopBackgroundPreview(projectDir, saved.port, dependencies); +): Promise { + if (!owned) return; + const stopped = await stopBackgroundPreview(projectDir, owned.port, dependencies); if (!stopped) throw new Error(`managed preview could not be replaced for ${resolve(projectDir)}`); - return null; } -function existingPreviewForLaunch( +function savedOwnedPreview( servers: ActiveServer[], saved: PreviewSession | null, projectDir: string, - dependencies: LifecycleDependencies, ): ActiveServer | null { - const requested = matchingServer(servers, projectDir, dependencies.browserGpuMode); - if (!dependencies.forceNew || !saved) return requested; + if (!saved) return null; // Ownership comes from the saved project+port, not the replacement's GPU // policy. Filtering here would miss an owned hardware→software replacement // and overwrite the only session record while leaving the old listener live. const savedPortServers = servers.filter((server) => server.port === saved.port); - return matchingServer(savedPortServers, projectDir) ?? requested; + return matchingServer(savedPortServers, projectDir); } export async function startBackgroundPreview( @@ -355,16 +369,29 @@ export async function startBackgroundPreview( // replace that owned server before recording the replacement, otherwise the // single per-project ownership record would orphan the old listener. const scanStart = saved?.port ?? startPort; - let existing = existingPreviewForLaunch(await scan(scanStart), saved, projectDir, dependencies); - if (existing && !dependencies.forceNew) { + const scanned = await scan(scanStart); + const requestedExisting = matchingServer(scanned, projectDir, dependencies.browserGpuMode); + const ownedExisting = savedOwnedPreview(scanned, saved, projectDir); + // A saved managed preview is the authoritative same-project instance. An + // explicit GPU-policy change replaces it; it must not silently adopt an + // unmanaged sibling that happens to match the new policy. + const reusableOwned = ownedExisting + ? matchingServer([ownedExisting], projectDir, dependencies.browserGpuMode) + : null; + const reusableExisting = reusableOwned ?? (ownedExisting ? null : requestedExisting); + if (reusableExisting && !dependencies.forceNew) { return { type: "reused", - port: existing.port, - pid: existing.pid ? Number(existing.pid) : null, + port: reusableExisting.port, + pid: reusableExisting.pid ? Number(reusableExisting.pid) : null, logPath: null, }; } - existing = await replaceOwnedPreviewForForceNew(existing, saved, projectDir, dependencies); + await stopOwnedPreviewBeforeReplacement(ownedExisting, projectDir, dependencies); + // Snapshot every same-project listener in the prospective launch range only + // after the owned listener is gone. Readiness must identify a newly appeared + // server, never a pre-existing unmanaged sibling. + const preLaunchPorts = sameProjectPorts(await scan(startPort), projectDir); const { pid, wrapperIdentity, logPath } = spawnDetachedPreview( projectDir, @@ -377,8 +404,7 @@ export async function startBackgroundPreview( const server = startedServer( await scan(startPort), projectDir, - existing, - dependencies.forceNew === true, + preLaunchPorts, dependencies.browserGpuMode, ); if (server) { @@ -413,7 +439,10 @@ export async function stopBackgroundPreview( const stateHome = dependencies.stateHome ?? defaultStateHome(); const saved = readPreviewSession(projectDir, stateHome); const scanStart = saved?.port ?? startPort; - const server = matchingServer(await scan(scanStart), projectDir); + const scanned = await scan(scanStart); + const server = saved + ? matchingServerAtPort(scanned, projectDir, saved.port) + : matchingServer(scanned, projectDir); if (!server) { removePreviewSession(projectDir, stateHome); return false; @@ -431,7 +460,7 @@ export async function stopBackgroundPreview( const sleep = dependencies.sleep ?? delay; for (let attempt = 0; attempt < 25; attempt++) { - if (!matchingServer(await scan(scanStart), projectDir)) { + if (!matchingServerAtPort(await scan(scanStart), projectDir, server.port)) { removePreviewSession(projectDir, stateHome); return true; }