From 10f55f5a5cfd9a33d47ecbf74c87fe76d7a4c67a Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Mon, 13 Jul 2026 17:50:59 +0300 Subject: [PATCH] feat(framework): bare `framework` runs the dashboard in the foreground; `--daemon` backgrounds it Closes #456. Bare `framework` now serves the dashboard in the foreground so its logs and server-thrown errors are visible and Ctrl+C stops it. `framework --daemon` does what bare `framework` used to: run detached in the background and return after printing the convenience commands. The detached child's server entry moved to an internal `--daemon-serve` flag (the background path spawns it), and bare `framework` defers to an already-running background daemon instead of fighting for the port. runDaemon gained an onListening hook for the foreground banner. --- .changeset/daemon-foreground.md | 5 +++ packages/framework/src/cli.test.ts | 45 ++++++++++++++++---- packages/framework/src/cli.ts | 66 ++++++++++++++++++++++++------ packages/framework/src/daemon.ts | 8 +++- 4 files changed, 101 insertions(+), 23 deletions(-) create mode 100644 .changeset/daemon-foreground.md diff --git a/.changeset/daemon-foreground.md b/.changeset/daemon-foreground.md new file mode 100644 index 0000000..3a571a3 --- /dev/null +++ b/.changeset/daemon-foreground.md @@ -0,0 +1,5 @@ +--- +"@gemstack/framework": minor +--- + +Bare `framework` now runs the dashboard server in the foreground (#456): Ctrl+C stops it, and the server's logs and any errors it throws are visible in the terminal. `framework --daemon` does what bare `framework` used to do, running the dashboard in the background (detached) and returning after printing the convenience commands. If a background daemon is already running, bare `framework` reports its URL and defers instead of fighting for the port. diff --git a/packages/framework/src/cli.test.ts b/packages/framework/src/cli.test.ts index 3e92a82..cba10ff 100644 --- a/packages/framework/src/cli.test.ts +++ b/packages/framework/src/cli.test.ts @@ -2,7 +2,7 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, dirname } from 'node:path' import { appendControl } from './control.js' import { daemonStatePath } from './daemon.js' import { EVENTS_FILE, FRAMEWORK_DIR } from './store/index.js' @@ -179,13 +179,17 @@ test('runCli prompt runs the text through the direct path (#353)', async () => { assert.ok(out.some(l => /prompt run done/.test(l))) }) -test('parseArgs reads the stop subcommand and the internal --daemon flag', () => { +test('parseArgs reads the stop subcommand and the --daemon / internal --daemon-serve flags (#456)', () => { const stop = parseArgs(['stop']) assert.equal(stop.stop, true) assert.equal(stop.intent, '') // "stop" is a command, not build intent const daemon = parseArgs(['--daemon', '--port', '4477']) assert.equal(daemon.daemon, true) + assert.equal(daemon.daemonServe, false) assert.equal(daemon.port, 4477) + const serve = parseArgs(['--daemon-serve', '--port', '4477']) + assert.equal(serve.daemonServe, true) + assert.equal(serve.daemon, false) assert.equal(parseArgs([]).stop, false) // bare invocation is not stop }) @@ -433,15 +437,38 @@ test('runCli usage error exits 2', async () => { assert.equal(await runCli(['--bogus'], io), 2) }) -test('runCli with no prompt ensures the background dashboard, not a usage error (#302)', async () => { +test('runCli bare framework foregrounds the dashboard, deferring to a running background daemon (#456)', async () => { + const { io, out } = capture() + const cfg = await mkdtemp(join(tmpdir(), 'framework-fg-')) + const prevXdg = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = cfg + try { + // Seed a live background daemon (this process is alive) so the foreground path + // short-circuits before binding a port and blocking the test. + const statePath = daemonStatePath(process.env) + await mkdir(dirname(statePath), { recursive: true }) + await writeFile( + statePath, + JSON.stringify({ pid: process.pid, port: 4200, url: 'http://localhost:4200', startedAt: new Date().toISOString() }), + ) + const code = await runCli(['--cwd', cfg], io) + assert.equal(code, 0) + assert.ok(out.some(l => /already running in the background/.test(l))) + } finally { + if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = prevXdg + await rm(cfg, { recursive: true, force: true }) + } +}) + +test('runCli --daemon backgrounds the dashboard, not a usage error (#302/#456)', async () => { const { io, err } = capture() - const cwd = await mkdtemp(join(tmpdir(), 'framework-bare-')) + const cwd = await mkdtemp(join(tmpdir(), 'framework-daemon-')) try { - // Bare `framework` routes to ensureDaemonCmd, not the old "describe what to build" - // usage error. The daemon spawn is refused from a test entry (it would re-exec this - // test file and fork-bomb), so it degrades to exit 1 — the point is it never spawns - // and is no longer exit 2. - const code = await runCli(['--cwd', cwd], io) + // `--daemon` routes to ensureDaemonCmd. The spawn is refused from a test entry (it + // would re-exec this test file and fork-bomb), so it degrades to exit 1 — the point + // is it never spawns and is not a usage error (exit 2). + const code = await runCli(['--daemon', '--cwd', cwd], io) assert.notEqual(code, 2) assert.ok(err.some(l => /dashboard daemon/.test(l))) } finally { diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index 4dc4a84..1b5b9ed 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -110,7 +110,8 @@ export function frameworkVersion(): string { const HELP = `The Framework — turnkey AI orchestration that wraps a coding agent (Claude Code). Usage: - framework Ensure the background dashboard is running; print commands. + framework Run the dashboard in the foreground (Ctrl+C stops it; logs visible). + framework --daemon Run the dashboard in the background; print commands and return. framework [intent...] Build what you describe, from scratch. framework stop Stop the background dashboard for this workspace. framework research [what] Rate the "problem variability" of (default: @@ -254,8 +255,10 @@ export interface CliOptions { skipPermissions: boolean resume: boolean persist: boolean - /** Run the persistent dashboard daemon body (internal; the spawned child sets this). */ + /** `framework --daemon`: run the dashboard in the background (detached), then return (#456). */ daemon: boolean + /** Serve the dashboard in-process — the detached child's entry, spawned by the background path (internal, #456). */ + daemonServe: boolean /** `framework stop`: stop the background daemon for this workspace. */ stop: boolean /** `framework research [what]`: run the Research preset as a direct prompt (#331). */ @@ -295,6 +298,7 @@ export function parseArgs(argv: string[]): CliOptions { resume: false, persist: true, daemon: false, + daemonServe: false, stop: false, research: false, directPrompt: false, @@ -366,6 +370,9 @@ export function parseArgs(argv: string[]): CliOptions { case '--daemon': opts.daemon = true break + case '--daemon-serve': + opts.daemonServe = true + break case '--no-persist': opts.persist = false break @@ -711,13 +718,17 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise { + const cwd = opts.cwd ?? process.cwd() + const port = opts.port ?? DEFAULT_DAEMON_PORT + const existing = await daemonStatus() + if (existing) { + io.out(`◆ dashboard already running in the background: ${existing.url}`) + io.out(' Stop it with `framework stop`, or open the URL above.') + return 0 + } + try { + await runDaemon(cwd, { + port, + onListening: state => { + io.out(`◆ dashboard running: ${state.url}`) + io.out(' Ctrl+C to stop. Server logs stream below.') + }, + }) + } catch (err) { + io.err(`could not start the dashboard (${err instanceof Error ? err.message : String(err)}).`) + return 1 + } + return 0 +} + +/** + * `framework --daemon` (#456): ensure the persistent background dashboard is running + * for this workspace, then print the URL, the convenience commands, and the version. + * Idempotent — a second call just re-reports the live one. Bare `framework` foregrounds + * the same server instead. */ async function ensureDaemonCmd(opts: CliOptions, io: CliIO): Promise { const cwd = opts.cwd ?? process.cwd() diff --git a/packages/framework/src/daemon.ts b/packages/framework/src/daemon.ts index a4f221a..f05b74c 100644 --- a/packages/framework/src/daemon.ts +++ b/packages/framework/src/daemon.ts @@ -163,7 +163,7 @@ export async function ensureDaemon(cwd: string, opts: EnsureDaemonOptions = {}): throw new Error('refusing to spawn the dashboard daemon from a test entry; pass an explicit binPath') } - const child = spawn(process.execPath, [binPath, '--daemon', '--cwd', cwd, '--port', String(port)], { + const child = spawn(process.execPath, [binPath, '--daemon-serve', '--cwd', cwd, '--port', String(port)], { detached: true, stdio: 'ignore', }) @@ -226,10 +226,13 @@ export interface RunDaemonOptions { binPath?: string /** Env for the global liveness path (#393). Default `process.env`; injectable for tests. */ env?: NodeJS.ProcessEnv + /** Called once the server has bound and recorded its state, before it blocks (#456). For the foreground banner. */ + onListening?: (state: DaemonState) => void } /** - * The daemon body — run in the detached child. Serves the prerendered Vike + Telefunc + * The daemon body — run in the foreground by bare `framework`, or in the detached child that + * `framework --daemon` spawns (#456). Serves the prerendered Vike + Telefunc * dashboard (#405/#426): the SPA reads each project's `.the-framework/events.jsonl` over a * Telefunc Channel and steers over control.jsonl, so the daemon just serves the bundle, * spawns runs, and records its liveness. Resolves on SIGINT/SIGTERM after tearing the @@ -364,6 +367,7 @@ export async function runDaemon(cwd: string, opts: RunDaemonOptions = {}): Promi const state: DaemonState = { pid: process.pid, port: actualPort, url: dashboard.url, startedAt: new Date().toISOString() } await mkdir(dirname(daemonStatePath(env)), { recursive: true }) await writeFile(daemonStatePath(env), JSON.stringify(state, null, 2)) + opts.onListening?.(state) } catch (err) { // Startup failed after the port was bound. Tear the server down, or it keeps the // event loop alive: a zombie daemon squatting the port with no state file, which