Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/daemon-foreground.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 36 additions & 9 deletions packages/framework/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
})

Expand Down Expand Up @@ -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 {
Expand Down
66 changes: 54 additions & 12 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <what> (default:
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -295,6 +298,7 @@ export function parseArgs(argv: string[]): CliOptions {
resume: false,
persist: true,
daemon: false,
daemonServe: false,
stop: false,
research: false,
directPrompt: false,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -711,13 +718,17 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
// the dashboard rehydrates exactly as it looked, then leave it up read-only.
if (opts.resume) return resumeRun(opts, io)

// `--daemon` is the detached child's own entry: it *is* the persistent dashboard,
// tailing .the-framework/ until signalled. Never invoked by hand (#302).
if (opts.daemon) {
// `--daemon-serve` is the detached child's own entry: it *is* the persistent dashboard,
// serving until signalled. Internal — the background `--daemon` path spawns it (#456).
if (opts.daemonServe) {
await runDaemon(opts.cwd ?? process.cwd(), opts.port !== undefined ? { port: opts.port } : {})
return 0
}

// `framework --daemon` runs the dashboard in the background (detached) and returns, printing
// the convenience commands (#456). Bare `framework` foregrounds it instead (below).
if (opts.daemon) return ensureDaemonCmd(opts, io)

// `framework stop` stops this workspace's background dashboard.
if (opts.stop) return stopDaemonCmd(opts, io)

Expand All @@ -727,16 +738,16 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num

const fake = opts.fake
const intent = opts.intent || (fake ? FAKE_INTENT : '')
// Bare `framework` (no prompt): ensure the persistent dashboard is running and
// print the convenience commands + version (#299/#302). A prompt still builds.
// A bare `framework prompt` has nothing to run — verbatim text is required.
// Bare `framework` (no prompt): run the dashboard server in the foreground so its logs
// (and server-thrown errors) are visible, and Ctrl+C stops it (#456). A prompt still
// builds. A bare `framework prompt` has nothing to run — verbatim text is required.
if (opts.directPrompt && !intent) {
io.err('framework prompt needs the prompt text, e.g. `framework prompt "review the auth flow"`.')
io.err('Run `framework --help` for usage.')
return 2
}
// A bare `framework research` is a real run — its "what" defaults to `this PR`.
if (!intent && !fake && !opts.research) return ensureDaemonCmd(opts, io)
if (!intent && !fake && !opts.research) return runForegroundDaemonCmd(opts, io)

const cwd = opts.cwd ?? (fake ? join(tmpdir(), 'framework-fake-workspace') : process.cwd())

Expand Down Expand Up @@ -1212,9 +1223,40 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
* a run URL can watch; accounts/teams/steering come later.
*/
/**
* `framework` (no prompt): ensure the persistent background dashboard is running
* for this workspace, then print the URL, the convenience commands, and the
* version (#299/#302). Idempotent — a second call just re-reports the live one.
* Bare `framework` (no prompt): run the dashboard server in the foreground (#456), so its
* logs and any server-thrown errors are visible and Ctrl+C stops it. If a background daemon
* (`framework --daemon`) already owns the port, defer to it rather than fight for the bind.
* Blocks until the server is signalled (SIGINT/SIGTERM).
*/
async function runForegroundDaemonCmd(opts: CliOptions, io: CliIO): Promise<number> {
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<number> {
const cwd = opts.cwd ?? process.cwd()
Expand Down
8 changes: 6 additions & 2 deletions packages/framework/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading