From 41cf56617afa4d8c902a95786e779ec130828d80 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 27 Mar 2026 08:13:59 -0600 Subject: [PATCH] feat: add $D serve command for HTTP-based comparison board feedback The comparison board feedback loop was fundamentally broken: browse blocks file:// URLs (url-validation.ts:71), so $B goto file://board.html always fails. The fallback open + $B eval polls a different browser instance. $D serve fixes this by serving the board over HTTP on localhost. The server is stateful: stays alive across regeneration rounds, exposes /api/progress for the board to poll, and accepts /api/reload from the agent to swap in new board HTML. Stdout carries feedback JSON only; stderr carries telemetry. Co-Authored-By: Claude Opus 4.6 (1M context) --- design/src/cli.ts | 21 +++- design/src/commands.ts | 9 +- design/src/serve.ts | 227 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 design/src/serve.ts diff --git a/design/src/cli.ts b/design/src/cli.ts index e73caca3..1c72b816 100644 --- a/design/src/cli.ts +++ b/design/src/cli.ts @@ -23,6 +23,7 @@ import { extractDesignLanguage, updateDesignMd } from "./memory"; import { diffMockups, verifyAgainstMockup } from "./diff"; import { evolve } from "./evolve"; import { generateDesignToCodePrompt } from "./design-to-code"; +import { serve } from "./serve"; function parseArgs(argv: string[]): { command: string; flags: Record } { const args = argv.slice(2); // skip bun/node and script path @@ -134,10 +135,15 @@ async function main(): Promise { // Parse --images as glob or multiple files const imagesArg = flags.images as string; const images = await resolveImagePaths(imagesArg); - compare({ - images, - output: (flags.output as string) || "/tmp/gstack-design-board.html", - }); + const outputPath = (flags.output as string) || "/tmp/gstack-design-board.html"; + compare({ images, output: outputPath }); + // If --serve flag is set, start HTTP server for the board + if (flags.serve) { + await serve({ + html: outputPath, + timeout: flags.timeout ? parseInt(flags.timeout as string) : 600, + }); + } break; } @@ -230,6 +236,13 @@ async function main(): Promise { output: (flags.output as string) || "/tmp/gstack-evolved.png", }); break; + + case "serve": + await serve({ + html: flags.html as string, + timeout: flags.timeout ? parseInt(flags.timeout as string) : 600, + }); + break; } } diff --git a/design/src/commands.ts b/design/src/commands.ts index b077d3df..70c174e3 100644 --- a/design/src/commands.ts +++ b/design/src/commands.ts @@ -36,8 +36,8 @@ export const COMMANDS = new Map { + const { html, port = 0, timeout = 600 } = options; + + // Validate HTML file exists + if (!fs.existsSync(html)) { + console.error(`SERVE_ERROR: HTML file not found: ${html}`); + process.exit(1); + } + + let htmlContent = fs.readFileSync(html, "utf-8"); + let state: ServerState = "serving"; + let timeoutTimer: ReturnType | null = null; + + const server = Bun.serve({ + port, + fetch(req) { + const url = new URL(req.url); + + // Serve the comparison board HTML + if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { + // Inject the server URL so the board can POST feedback + const injected = htmlContent.replace( + "", + `\n` + ); + return new Response(injected, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + + // Progress polling endpoint (used by board during regeneration) + if (req.method === "GET" && url.pathname === "/api/progress") { + return Response.json({ status: state }); + } + + // Feedback submission from the board + if (req.method === "POST" && url.pathname === "/api/feedback") { + return handleFeedback(req); + } + + // Reload endpoint (used by the agent to swap in new board HTML) + if (req.method === "POST" && url.pathname === "/api/reload") { + return handleReload(req); + } + + return new Response("Not found", { status: 404 }); + }, + }); + + const actualPort = server.port; + const boardUrl = `http://localhost:${actualPort}`; + + console.error(`SERVE_STARTED: port=${actualPort} html=${html}`); + + // Auto-open in user's default browser + openBrowser(boardUrl); + + // Set timeout + timeoutTimer = setTimeout(() => { + console.error(`SERVE_TIMEOUT: after=${timeout}s`); + server.stop(); + process.exit(1); + }, timeout * 1000); + + async function handleFeedback(req: Request): Promise { + let body: any; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + // Validate expected shape + if (typeof body !== "object" || body === null) { + return Response.json({ error: "Expected JSON object" }, { status: 400 }); + } + + const isSubmit = body.regenerated === false; + const isRegenerate = body.regenerated === true; + const action = isSubmit ? "submitted" : (body.regenerateAction || "regenerate"); + + console.error(`SERVE_FEEDBACK_RECEIVED: type=${action}`); + + // Print feedback JSON to stdout (agent reads this) + console.log(JSON.stringify(body)); + + if (isSubmit) { + // Write feedback.json next to the HTML file + const feedbackPath = path.join(path.dirname(html), "feedback.json"); + fs.writeFileSync(feedbackPath, JSON.stringify(body, null, 2)); + + state = "done"; + if (timeoutTimer) clearTimeout(timeoutTimer); + + // Give the response time to send before exiting + setTimeout(() => { + server.stop(); + process.exit(0); + }, 100); + + return Response.json({ received: true, action: "submitted" }); + } + + if (isRegenerate) { + state = "regenerating"; + // Reset timeout for regeneration (agent needs time to generate new variants) + if (timeoutTimer) clearTimeout(timeoutTimer); + timeoutTimer = setTimeout(() => { + console.error(`SERVE_TIMEOUT: after=${timeout}s (during regeneration)`); + server.stop(); + process.exit(1); + }, timeout * 1000); + + return Response.json({ received: true, action: "regenerate" }); + } + + return Response.json({ received: true, action: "unknown" }); + } + + async function handleReload(req: Request): Promise { + let body: any; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const newHtmlPath = body.html; + if (!newHtmlPath || !fs.existsSync(newHtmlPath)) { + return Response.json( + { error: `HTML file not found: ${newHtmlPath}` }, + { status: 400 } + ); + } + + // Swap the HTML content + htmlContent = fs.readFileSync(newHtmlPath, "utf-8"); + state = "serving"; + + console.error(`SERVE_RELOADED: html=${newHtmlPath}`); + + // Reset timeout + if (timeoutTimer) clearTimeout(timeoutTimer); + timeoutTimer = setTimeout(() => { + console.error(`SERVE_TIMEOUT: after=${timeout}s`); + server.stop(); + process.exit(1); + }, timeout * 1000); + + return Response.json({ reloaded: true }); + } + + // Keep the process alive + await new Promise(() => {}); +} + +/** + * Open a URL in the user's default browser. + * Handles macOS (open), Linux (xdg-open), and headless environments. + */ +function openBrowser(url: string): void { + const platform = process.platform; + let cmd: string; + + if (platform === "darwin") { + cmd = "open"; + } else if (platform === "linux") { + cmd = "xdg-open"; + } else { + // Windows or unknown — just print the URL + console.error(`SERVE_BROWSER_MANUAL: url=${url}`); + console.error(`Open this URL in your browser: ${url}`); + return; + } + + try { + const child = spawn(cmd, [url], { + stdio: "ignore", + detached: true, + }); + child.unref(); + console.error(`SERVE_BROWSER_OPENED: url=${url}`); + } catch { + // open/xdg-open not available (headless CI environment) + console.error(`SERVE_BROWSER_MANUAL: url=${url}`); + console.error(`Open this URL in your browser: ${url}`); + } +}