fix(cli): preview inside a host app's agent run no longer outlives the run (#5852)

A background preview detaches, so one started by an agent inside a host app's
run kept running after the run and the app ended, and held the project's
history lock for its whole life. Inside a host app's run, preview now refuses
to start in the background and points to the host app's preview or
--foreground, which stays attached to the run.
This commit is contained in:
Miguel Ángel
2026-10-18 22:15:19 +00:00
committed by GitHub
parent 388011b214
commit bf24fda8da
3 changed files with 61 additions and 5 deletions
+34
View File
@@ -14,6 +14,7 @@ import {
prebuildPreview,
handlePreviewKillAll,
handlePreviewList,
previewHostAppError,
previewLaunchMode,
previewLaunchModeError,
previewPortError,
@@ -601,3 +602,36 @@ describe("studio dev-server spawns", () => {
expect(spawn.mock.calls[0]?.[2]).toMatchObject({ windowsHide: true });
});
});
describe("preview inside a host app's agent run", () => {
const hostRun = { HYPERFRAMES_DESKTOP_PROJECT: "/projects/demo" };
it("refuses a background preview, which would outlive the run", () => {
expect(previewHostAppError("background", hostRun)).toMatch(/host app .* owns its preview/);
expect(previewHostAppError("background", hostRun)).toContain("--foreground");
});
it("allows a preview attached to the run, and background outside a host app", () => {
expect(previewHostAppError("embedded", hostRun)).toBeNull();
expect(previewHostAppError("local", hostRun)).toBeNull();
expect(previewHostAppError("background", {})).toBeNull();
});
it("refuses before spawning anything", async () => {
vi.stubEnv("HYPERFRAMES_DESKTOP_PROJECT", "/projects/demo");
const project = mkdtempSync(join(tmpdir(), "hf-host-preview-"));
writeFileSync(join(project, "index.html"), "<html><body></body></html>");
const log = vi.spyOn(console, "log").mockImplementation(() => {});
let printed = "";
try {
await runCommand(previewCommand, { rawArgs: [project, "--background", "--json"] });
printed = String(log.mock.calls.at(-1)?.[0]);
} finally {
log.mockRestore();
vi.unstubAllEnvs();
rmSync(project, { recursive: true, force: true });
}
const payload = JSON.parse(printed);
expect(payload).toMatchObject({ ok: false, error: { code: "host-app-owns-preview" } });
});
});
+22 -4
View File
@@ -39,7 +39,7 @@ import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { desktopHint } from "../utils/desktopApp.js";
import { desktopHint, insideHostApp } from "../utils/desktopApp.js";
import { isDevMode } from "../utils/env.js";
import { normalizeErrorMessage as errorMessage } from "../utils/errorMessage.js";
import { buildNpxCommand } from "../utils/npxCommand.js";
@@ -488,9 +488,6 @@ export default defineCommand({
);
}
// Runs in the CLI worker while Studio starts, so it never delays the opening; --json skips it.
const startupLint = args.json ? null : printStartupLint(dir, Boolean(args["lint-verbose"]));
const launchMode = previewLaunchMode({
background: Boolean(args.background),
foreground: Boolean(args.foreground),
@@ -499,6 +496,15 @@ export default defineCommand({
localStudio: hasLocalStudio(dir),
});
const hostAppError = previewHostAppError(launchMode);
if (hostAppError) {
reportPreviewFailure(Boolean(args.json), "start", "host-app-owns-preview", hostAppError);
return;
}
// Runs in the CLI worker while Studio starts, so it never delays the opening; --json skips it.
const startupLint = args.json ? null : printStartupLint(dir, Boolean(args["lint-verbose"]));
if (launchMode === "background") {
let background;
try {
@@ -643,6 +649,18 @@ export function previewLaunchMode(options: {
return options.localStudio ? "local" : "embedded";
}
export function previewHostAppError(
mode: PreviewLaunchMode,
env: NodeJS.ProcessEnv = process.env,
): string | null {
if (mode !== "background" || !insideHostApp(env)) return null;
return (
"A host app is running this project and owns its preview, so a background preview " +
"here would outlive this run and keep the project's history locked. Use the host " +
"app's preview, or run with --foreground to keep a preview attached to this run."
);
}
export function previewLaunchModeError(options: {
background: boolean;
foreground: boolean;
+5 -1
View File
@@ -221,6 +221,10 @@ export function desktopInstalled({
return DESKTOP_BUNDLE_IDS.some(spotlight);
}
export function insideHostApp(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(env.HYPERFRAMES_DESKTOP_PROJECT);
}
/** The app line render and preview print; null inside the app, or with nothing here to open or download. */
export function desktopHint(
dir: string,
@@ -236,7 +240,7 @@ export function desktopHint(
platform?: NodeJS.Platform;
} = {},
): string | null {
if (env.HYPERFRAMES_DESKTOP_PROJECT) return null;
if (insideHostApp(env)) return null;
if (ready && (installed ?? desktopInstalled({ platform, env })))
return `Keep editing by chatting with Framey in the desktop app: ${openCommandFor(dir)}`;
const url = desktopDownloadUrl(platform);