framework
Version:
The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.
1,240 lines • 69.7 kB
JavaScript
import { readFileSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DRIVER_SPECS, isDriverName } from './driver-cli.js';
import { createAgentDriver } from './agent-driver.js';
import { checkLayout } from './layout.js';
import { githubSlugFor } from './dashboard/github.js';
import { githubToken } from './dashboard/gh.js';
import { launchSharedBrowser, withBrowser } from './browser.js';
import { connectCdp, startBrowserStream } from './browser-stream.js';
import { formatFrameworkEvent, mergeWithheldWhy } from './terminal.js';
import { defuseClosingKeywords } from './closing-keywords.js';
import { CLAUDE_CODE_SESSION_LINK } from './session-link.js';
import { agentAutoHandoff, withheldMerge } from './dashboard/agent-handoff.js';
import { runAgent, } from './agent.js';
import { FAKE_INTENT, fakeDriver } from './fake-script.js';
import { isTicketPath, ticketIssueRef } from './tickets.js';
import { readDataFile } from './data-branch.js';
import { isHandsOff, isAgentLocation } from './agent-location.js';
import { handoffStages, isHandoffLevel } from './handoff-level.js';
import { readAgentSpec, removeAgentSpec, writeAgentSpec } from './agent-spec.js';
import { agentTodoPending } from './todo-loop.js';
import { loadFrameworkConfig } from './config.js';
import { describeResolvedConfig, fileConfigLayer, resolveAgentConfig, } from './config-layers.js';
import { loadUserSystemPrompt, SYSTEM_PROMPT_FILE } from './system-prompt-file.js';
import { checkForUpdate, formatUpdateStatus, nodeVersionFetcher } from './update-check.js';
import { AgentStore, commitPendingWork, currentBranch, nodeStoreFs, renameAgentBranch, agentBranchName, AGENT_BRANCH_PREFIX } from './store/index.js';
import { materializePresets } from './presets.js';
import { isLoopbackHost, runDaemon, DEFAULT_DAEMON_HOST, DEFAULT_DAEMON_PORT } from './daemon.js';
import { resetControl, watchControl } from './control.js';
import { AgentMessageQueue } from './agent-messages.js';
import { createGateKeepalive } from './gate-keepalive.js';
import { ensureDaemonToken } from './registry.js';
import { defaultWhat } from './preset-prompt.js';
import { renderOnBeforeMergeablePrompt } from './on-before-mergeable-prompt.js';
import { presets } from './preset-catalog.js';
import { errorMessage } from './error-message.js';
/**
* The default link shown for a live agent: the generic Claude Code entry point,
* surfaced as "Open Claude Code" (not a per-agent live session). We drive Claude
* Code headless, which is not Remote-Controlled, so there is no per-session deep
* link to construct (#214); a cloud run reports its real link through its own
* driver events instead.
*/
export const CLAUDE_CODE_SESSION_LIST = CLAUDE_CODE_SESSION_LINK;
/**
* The session link to show for an agent: the generic Claude Code entry point for
* a live agent, nothing for a fake one (which has no real session). Pure, so the
* default is unit-testable without a live agent.
*
* The default is Claude Code's *own* entry point, so it is only honest on a
* Claude run: pointing a Codex session at claude.ai/code offers the user a link
* to somewhere their run isn't. Codex keeps its sessions locally with nothing
* equivalent to open, so another agent gets no default link at all (#542).
*/
export function chooseSessionLink(opts, fake) {
return fake || opts.driver !== 'claude' ? undefined : CLAUDE_CODE_SESSION_LIST;
}
const defaultIO = {
out: line => process.stdout.write(line + '\n'),
err: line => process.stderr.write(line + '\n'),
};
/**
* The CLI version, read from the package's own `package.json` at runtime (#312).
* The compiled entry lives one level under the package root (`dist/` or
* `dist-test/`), so its `package.json` is always `../package.json`. Cached after
* the first read; falls back to `unknown` if the file is somehow unreadable.
*/
let cachedVersion;
export function frameworkVersion() {
if (cachedVersion !== undefined)
return cachedVersion;
try {
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
// `unknown`, not `0.0.0`: the packages are unreleased and legitimately versioned `0.0.0`, so a
// numeric fallback would make a failed read indistinguishable from a correct one (#312).
cachedVersion = pkg.version ?? 'unknown';
}
catch {
cachedVersion = 'unknown';
}
return cachedVersion;
}
const HELP = `The Framework — turnkey AI orchestration that wraps a coding agent (Claude Code or Codex).
Usage:
framework Serve the dashboard in the foreground. Ctrl+C closes it and every
session it is running; the server logs stream to this terminal.
Options:
--port <n> Dashboard port (default: 4200).
--host <addr> Bind address (default: 127.0.0.1, localhost only). A non-loopback
address (e.g. 0.0.0.0) exposes the dashboard to your network and
generates a shared token; the printed URL carries it, and any request
without it gets 401. Exposing a process spawner to the network is a
security decision (#806).
-h, --help Show this help.
-v, --version Print the version.
Everything else is the dashboard: it is the product's user interface, and where a session's
prompt, its options, its agent and its checkout are chosen. The dashboard spawns each session
as its own process, handing it one JSON spec rather than a command line.
The Framework drives the wrapped agent as a black box: it prompts, reads the code, and gates on
the outcome, then re-prompts. The dashboard foregrounds the loop status beside the agent's own
session.`;
/**
* Parse argv (without the node/script prefix). Pure and testable.
*
* Four options and nothing else. Everything a session needs used to be a flag here — sixty-seven
* of them, twenty-seven with no human user at all, because the dashboard serialized
* `StartAgentOptions` onto a command line. Those travel as a {@link AgentSpec} now. `--host` and
* `--port` survive because they are the two things a browser cannot be asked and a dashboard
* cannot serve about itself; `--help` and `--version` because a command with options owes the
* user both.
*/
export function parseArgs(argv) {
const opts = { help: false, version: false };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
case '--help':
case '-h':
opts.help = true;
break;
case '--version':
case '-v':
opts.version = true;
break;
case '--port': {
const n = Number(argv[++i]);
if (Number.isInteger(n) && n >= 0)
opts.port = n;
else
opts.error = 'invalid --port: must be a non-negative integer';
break;
}
case '--host': {
const value = argv[++i];
if (value === undefined)
opts.error = 'invalid --host: missing address';
else
opts.host = value;
break;
}
case '--agent': {
const value = argv[++i];
if (value === undefined)
opts.error = 'invalid --agent: missing path';
else
opts.session = value;
break;
}
default:
opts.error = arg.startsWith('-') ? `unknown option: ${arg}` : `unknown command: ${arg}`;
}
}
return opts;
}
/** The session defaults nothing sets any more, shared by {@link agentOptions} and the tests. */
const SESSION_DEFAULTS = {
driver: 'claude',
scope: 'full',
context: [],
onBeforeMergeable: false,
browser: false,
persist: true,
todoLoop: true,
};
/**
* Read a session's options off the spec the dashboard wrote (D4).
*
* The handoff pair and the mode toggles are left unset when the spec says nothing about them, on
* purpose: that is what lets the repo's `the-framework.yml` decide, with nobody setting them
* resolving to on (#1102/#841). JSON distinguishes "absent" from `false` without needing a
* `--no-*` spelling for each, which is the whole reason this stopped being an argv.
*/
export function agentOptions(spec, env = process.env) {
const o = spec.options;
const defined = (value) => value !== undefined;
return {
...SESSION_DEFAULTS,
context: o.context ?? [],
fake: env['FRAMEWORK_FAKE'] === '1',
intent: spec.prompt,
research: spec.kind === 'research',
directPrompt: spec.kind === 'prompt',
cwd: spec.cwd,
...(spec.agentId ? { agentId: spec.agentId } : {}),
...(spec.continueAgent ? { continueAgent: true } : {}),
...(isDriverName(o.driver) ? { driver: o.driver } : {}),
...(isAgentLocation(o.target) ? { target: o.target } : {}),
...(o.model?.trim() ? { model: o.model.trim() } : {}),
...(o.resumeSession?.trim() ? { resumeSession: o.resumeSession.trim() } : {}),
// The ticket comes off a queue file an agent writes, so it is re-checked here rather than
// trusted: a path that is not a ticket never reaches the agent (#1117).
...(o.ticket && isTicketPath(o.ticket) ? { ticket: o.ticket } : {}),
...(o.planAgent ? { planAgent: true } : {}),
...(o.unattended ? { unattended: true } : {}),
...(defined(o.vanilla) ? { vanilla: o.vanilla } : {}),
...(defined(o.transparent) ? { transparent: o.transparent } : {}),
...(isHandoffLevel(o.handoff) ? { handoff: o.handoff } : {}),
...(o.onBeforeMergeable ? { onBeforeMergeable: true } : {}),
...(o.browser ? { browser: true } : {}),
};
}
/**
* Resolve the Claude Code driver options for a live session. A session is a headless autonomous
* builder: every turn is `claude -p`, which cannot answer an interactive approval. The driver's
* library default (`acceptEdits`) silently denies installs/builds/tests, so the production-grade
* checklist can never verify the app actually builds/runs (#225). `bypassPermissions`, so the full
* loop runs unattended.
*
* There used to be a `--permission-mode` and a `--dangerously-skip-permissions` to override it.
* Neither had a dashboard control, so with the flags gone (D4) nothing sets them and the mode is
* simply what it always resolved to.
*/
export function claudeDriverOptions() {
return { permissionMode: 'bypassPermissions' };
}
/**
* The settings the picked driver cannot honor (#542), as lines to print at startup.
*
* A setting that silently does nothing is worse than one that errors. So the session says which
* settings are not in force, rather than letting them imply they are.
*/
export function unguardedNotices(opts) {
const spec = DRIVER_SPECS[opts.driver];
const notices = [];
if (opts.driver !== 'claude' && opts.browser) {
notices.push(`the browser has no effect on ${spec.label}: the browser tools are wired through Claude Code's MCP config.`);
}
return notices;
}
/** Which flow an agent starts under (#1467), recorded on its meta: the direct paths are prompts, a
* build agent is a build. Transparent (#625) routes a build-kind agent through the raw prompt path too,
* so it records as a prompt. */
export function agentLogKind(opts, transparent = false) {
return opts.directPrompt || opts.research || transparent ? 'prompt' : 'build';
}
/** This agent's own flags as the nearest config layer (#841). A flag left off says nothing. */
function flagConfigLayer(opts) {
return {
name: 'flag',
values: {
...(opts.vanilla !== undefined ? { vanilla: opts.vanilla } : {}),
...(opts.transparent !== undefined ? { transparent: opts.transparent } : {}),
...(opts.handoff !== undefined ? { handoff: opts.handoff } : {}),
},
};
}
/**
* Resolve an agent's config over its layers, nearest wins (#841): the agent's flags, then the repo's
* `the-framework.yml`. #800 slots the project-user and global tiers in between and at the end.
*/
export function mergeAgentConfig(opts, file) {
return resolveAgentConfig([flagConfigLayer(opts), fileConfigLayer(file)]);
}
/**
* Run one engine (a build or a direct prompt) and settle it identically: on success print its
* line; on a clean stop (interrupt / budget cap #322) report it; on a real failure report it. The
* teardown — flush the store, close the control channel + consumption guard — runs either way.
* Returns the exit code (0 on success or a clean stop, 1 on a failure). Shared by both agent paths
* so their epilogues cannot drift.
*/
async function settleAgent(ctx, run) {
const { io } = ctx;
try {
const { successLine } = await run();
ctx.clearInterrupt();
io.out(successLine);
// Before the close, not after (#835): close() archives the log into `agents/`, so an
// outcome appended afterwards would miss the copy the dashboard's history reads.
await ctx.maybeFireOnBeforeMergeable();
// After the quality step, so whatever it committed is in what gets pushed; before the close,
// for the same reason as above — `close()` archives the log, and an outcome appended after it
// would miss the copy the dashboard's history reads (#835).
await ctx.maybeAutoHandoff();
await ctx.store?.close(); // flush the event log; best-effort
return 0;
}
catch (err) {
ctx.clearInterrupt();
await ctx.store?.close();
// A clean stop (Stop button, Ctrl+C, or a budget cap #322) is not a failure: report it and
// exit 0. The dashboard that spawned this session shows the stopped state from its event log.
if (ctx.isStopped()) {
io.out('\n■ Stopped.');
return 0;
}
io.err(`\n✗ ${ctx.failLabel} failed: ${errorMessage(err)}`);
return 1;
}
finally {
ctx.clearInterrupt();
ctx.control?.close();
await ctx.browserStream?.close();
await ctx.sharedBrowser?.close();
}
}
/**
* Resolve the system-prompt configuration both agent paths share (#301/#314), echoing what
* is in effect: a user SYSTEM.md, the built-in prompt toggle, and the in-context dirs. Reads
* SYSTEM.md once, so call it on the shared path before the session starts.
*/
async function resolvePromptConfig(opts, config, cwd, io, transparent) {
const userSystemPrompt = await loadUserSystemPrompt(cwd);
// Transparent empties the whole channel, which subsumes "no built-in prompt".
const noBuiltinPrompt = transparent || config.vanilla;
if (userSystemPrompt)
io.out(`◆ system prompt: ${SYSTEM_PROMPT_FILE}`);
// Transparent already announced itself (guard line); don't double-report the prompt being off.
// Name the layer that turned it off.
const offBy = config.sources.vanilla ?? 'transparent';
if (noBuiltinPrompt && !transparent)
io.out(`◆ built-in system prompt: off (${offBy})`);
if (opts.context.length)
io.out(`◆ context: ${opts.context.join(', ')}`);
return { ...(userSystemPrompt ? { userSystemPrompt } : {}), noBuiltinPrompt };
}
/**
* The `framework` command. Wires the parsed options into {@link runFramework}
* over a live dashboard + terminal narration, and resolves with an exit code.
* Returns 0 on success, 1 on an agent error, 2 on a usage error.
*/
/**
* Whether this agent can be steered over `.the-framework/control.jsonl` (#344): Stop, a choice pick,
* a live message. True when its own dashboard is up (#427), or when whoever spawned it handed it a
* agent id — the dashboard spawns each session with one in its spec, and steers it
* from its own process.
*
* This used to have a third clause, "a daemon is alive somewhere on this machine", read from a
* global state file. That file is gone with the background daemon (D4b), and it was never a fact
* about *this* run in the first place: it went missing while the daemon was very much alive
* (#922), and every Stop press then landed in control.jsonl and was read by nobody, in silence
* (#905). An agent id holds when a file about another process does not.
*/
export function isSteerable(opts) {
return opts.persist && opts.agentId !== undefined;
}
/**
* Whether this session should stay open for the user's own messages once it settles (#714).
*
* The other half of #905. Being steerable only means someone *could* reach it; staying open means
* a human is expected to keep talking to it. A headless agent is neither, but it used to inherit the
* chat queue purely because a daemon happened to be alive elsewhere on the machine, and then
* parked forever on a message nothing could send. #714 said as much: "headless / CI runs end when
* done, exactly as today."
*
* So: the dashboard started it (an agent id) and therefore has a UI to carry on the conversation in.
* Stop and gate picks keep working either way.
*/
export function isInteractive(opts) {
return opts.agentId !== undefined;
}
/**
* Route Ctrl+C / SIGTERM into aborting the agent — not into default signal termination, which
* would kill the framework while its spawned Claude Code tree keeps running (the
* orphaned-process leak). Aborting drives the driver to group-kill its child; a second
* signal force-quits. Returns the disarm, called once the agent settles.
*/
function armInterrupt(controller, io) {
let interrupts = 0;
const onInterrupt = () => {
if (++interrupts === 1) {
io.err('\n■ Interrupt: stopping the session (Ctrl+C again to force-quit)…');
controller.abort();
}
else {
process.exit(130);
}
};
process.on('SIGINT', onInterrupt);
process.on('SIGTERM', onInterrupt);
return () => {
process.off('SIGINT', onInterrupt);
process.off('SIGTERM', onInterrupt);
};
}
/**
* The agent's event sink and the state its epilogue reads. One event arrives and this prints it,
* persists it, tracks the settle flags (#322/#326), renames the framework-owned branch once the
* agent names its session (#736), and re-emits a held browser-stream port right after `session` so
* it lands in the slice the dashboard renders (#829). These jobs sat inline in runCli across six
* mutable locals; the journal is their one owner, and runCli reads the getters. Exported for the
* rename-records-the-branch test (#1277).
*/
export function createAgentJournal(deps) {
const { io, cwd, store } = deps;
// The framework's own verdict that the agent stopped cleanly rather than failed — set by a
// user interrupt or a budget cap (#322). Trusted over which signal aborted, since a budget
// stop trips an internal signal the CLI never sees.
let stoppedCleanly = false;
let sawReadyForMerge = false;
let sessionName;
// The pull request the agent asked for (#1567), latest wins: it may revise it as the work
// changes, and the handoff wants what it said last.
let pullRequest;
// The browser preview's port, announced on the first `session` event rather than when the
// bridge opens (#829): the dashboard renders only the tail from the last `session` event, so
// anything emitted ahead of it is dropped from the agent's view.
let pendingBrowserPort;
// The page the browser preview is on (#1455 item 6b). Held the same way the port is until a
// session opens; after that a navigation emits straight away. Re-emitted after EVERY `session`
// (not just the first, unlike the port whose meta fold survives the slice): a continuation
// starts a fresh rendered slice, and without the re-say its transcript would have no browser
// row to host the pane.
let latestBrowserUrl;
let agentOpen = false;
const onEvent = (event) => {
if (event.kind === 'ready-for-merge')
sawReadyForMerge = true;
if (event.kind === 'open-pr')
pullRequest = { ...(event.title ? { title: event.title } : {}), ...(event.description ? { description: event.description } : {}) };
if (event.kind === 'session-name') {
sessionName = event.name;
// The framework-owned checkout (#736) was branched as `tf-agent-<id>` before a
// name existed; put the readable name on it now. No-ops when the agent branched itself,
// and only a rename that happened is recorded (#1277) — a guessed name on the meta is
// exactly what the branch event exists to end.
if (deps.agentId) {
const renamed = `${AGENT_BRANCH_PREFIX}${event.name}`;
void renameAgentBranch(cwd, agentBranchName(deps.agentId), renamed).then(didRename => {
if (didRename)
onEvent({ kind: 'branch', branch: renamed });
});
}
}
if (event.kind === 'end' && event.stopped)
stoppedCleanly = true;
io.out(formatFrameworkEvent(event));
void store?.append(event);
// Right after the session opens, so it lands inside the slice the dashboard renders.
if (event.kind === 'session') {
agentOpen = true;
if (pendingBrowserPort !== undefined) {
const port = pendingBrowserPort;
pendingBrowserPort = undefined;
onEvent({ kind: 'browser-stream', port });
}
if (latestBrowserUrl !== undefined)
onEvent({ kind: 'browser', url: latestBrowserUrl });
}
};
return {
onEvent,
sessionName: () => sessionName,
sawReadyForMerge: () => sawReadyForMerge,
pullRequest: () => pullRequest,
stoppedCleanly: () => stoppedCleanly,
announceBrowserPort: port => {
pendingBrowserPort = port;
},
announceBrowserUrl: url => {
latestBrowserUrl = url;
if (agentOpen)
onEvent({ kind: 'browser', url });
},
};
}
export async function runCli(argv, io = defaultIO) {
const args = parseArgs(argv);
if (args.error) {
io.err(args.error);
io.err('Run `framework --help` for usage.');
return 2;
}
if (args.help) {
io.out(HELP);
return 0;
}
if (args.version) {
io.out(frameworkVersion());
return 0;
}
// `--agent <path>`: the dashboard's process API (D4). One session, described entirely by the
// JSON spec at that path — which is consumed as it is read, so a device token in its options
// does not outlive the session that used it.
if (args.session !== undefined) {
let spec;
try {
spec = await readAgentSpec(args.session);
}
catch (err) {
io.err(`could not read the session spec (${errorMessage(err)}).`);
return 2;
}
return driveAgent(agentOptions(spec), io);
}
// Everything else is bare `framework`: serve the dashboard in the foreground until Ctrl-C.
return runForegroundDaemonCmd(args, io);
}
/**
* Everything the CLI wires around one session before {@link runAgent} drives it: config
* resolved over the layers, the store, the control channel, the browser and the journal — then
* handed to {@link settleAgent}. Returns the process exit code. Split out of
* {@link runCli} so the top reads as a dispatch table and this reads as one session's lifecycle.
*/
async function driveAgent(opts, io) {
const fake = opts.fake;
const intent = opts.intent || (fake ? FAKE_INTENT : '');
// A `prompt` session runs verbatim text, so it needs some. `research` is the one kind whose
// empty prompt is fine — its "what" falls back to the preset default.
if (!intent && !fake && !opts.research) {
io.err('this session has no prompt to run.');
return 2;
}
const cwd = opts.cwd ?? (fake ? join(tmpdir(), 'framework-fake-workspace') : process.cwd());
// The layout gate (#1575): a build whose bookkeeping layout differs from what the repo records
// is refused outright before it writes anything — no degraded mode, same stance as the
// extension gate (#1519). The case it exists for: the cloud environment installs the framework
// from npm, and a published build that predates a repo-side rename would otherwise commit its
// session archive under a name the repo has moved away from (#1574). An unmarked repo (no
// tracked marker — the fake demo's tmp workspace, say) passes ungated.
const layout = await checkLayout(cwd);
if (!layout.ok) {
io.err(layout.error);
return 1;
}
// The project can carry its own defaults in the-framework.yml (#258): the prompt switches and
// how far a finished agent publishes itself. A bad file is a warning, never a failed agent. Read
// from the agent's own workspace, so the fake demo (an empty tmp cwd) stays deterministic unless
// it is pointed at a config dir.
const fileConfig = await loadFrameworkConfig(cwd, msg => io.err(msg));
// One resolve over the layers (#841): the nearest layer that set a key wins, so what the caller
// asked for beats the repo file. Not CLI flags — the CLI has no such flags since D4; these are
// the spec the dashboard (or an embedder) handed this process, and `false` there is an answer,
// not an absence, so it can turn off what the file set.
const config = mergeAgentConfig(opts, fileConfig);
const fromConfig = describeResolvedConfig(config);
if (fromConfig)
io.out(`◆ config: ${fromConfig}`);
// Transparent mode (#625): the coarse master off-switch — `--transparent` or the-framework.yml.
// When on, this agent is byte-identical to raw `claude -p`: no framework system channel (composed
// empty below), no dashboard, no TODO loop. Resolved once here so every
// one of those sites reads the same answer.
const transparent = config.transparent;
// Only the direct-prompt path resumes a conversation (#782): a build agent rebuilds the
// scope/build framing a resumed transcript already carries, so it takes no session id and
// used to drop the flag on the floor. Silently losing the context you asked to continue
// from is the worst outcome, so say so and stop rather than run a fresh session that looks
// like a resumed one. The exception is a continuation (#1467): `--continue-run` re-enters an
// existing run, whose recorded flow decides the path — there the session id is the point.
if (opts.resumeSession && !(opts.research || opts.directPrompt || transparent || opts.continueAgent)) {
io.err('a resumed agent session only applies to a prompt session, not a build.');
io.err('Run `framework --help` for usage.');
return 2;
}
// No preflight here. The dashboard already ran it before spawning this session (#1326,
// `daemon-runtime`'s `driverPreflight`) and refused the start outright if the picked agent could
// not run, so a doomed session spends no branch and no worktree — and the report lands on the
// surface the user is looking at, rather than in a log they have to go and find. Re-running it
// here was the same check a second time, and it was wrong for a session that runs somewhere else
// entirely: `--run-on actions` needs no local agent CLI at all, which is exactly why the
// dashboard's own check skips it.
// Which agent is about to spend the user's subscription, and which settings are not in force
// while it does — said *before* the first turn (#542).
if (!fake) {
if (opts.driver !== 'claude')
io.out(`◆ driver: ${DRIVER_SPECS[opts.driver].label}`);
for (const note of unguardedNotices(opts))
io.err(`note: ${note}`);
}
const claudeOpts = claudeDriverOptions();
// Computed once here, reused by extension discovery and the agent.
// One controller for the whole agent: the dashboard Stop button aborts it once
// wired below.
const controller = new AbortController();
// Ctrl+C / SIGTERM during a live agent aborts the agent; disarmed once it settles so the
// post-run dashboard wait keeps its own Ctrl+C handling.
const clearInterrupt = armInterrupt(controller, io);
// The dashboard Stop button aborts the run-wide controller created above.
// runFramework checks the signal between phases and the driver kills its current
// turn on it, so a stop takes effect promptly.
//
// Interactive choices (#304): the agent's requestChoice handler parks a resolver
// here keyed by the choice id; the dashboard's Accept / autopilot POSTs the pick
// to /choice, which resolves it. Aborting the agent resolves any pending choice
// (proceed) so the gate never hangs a stopped agent.
const pendingChoices = new Map();
// Live-chat messages the user sends to the running agent (#714). The control watcher
// pushes each here; the run loop drains them between turns and waits here when idle.
const messages = new AgentMessageQueue();
controller.signal.addEventListener('abort', () => {
for (const resolve of pendingChoices.values())
resolve({ picked: 'proceed', by: 'auto' });
pendingChoices.clear();
messages.close();
});
// No per-session dashboard (D3). The one that spawned this session is already serving the UI,
// reading this session's event log off disk and steering it through control.jsonl — the file is
// the seam, so a second server on a second port was only ever a second implementation of the
// product's front door.
// Persist the orchestration state so a restart can --resume it (#211). The log
// is the dashboard's own event stream, appended to .the-framework/ in the workspace.
// Best-effort: a store that fails to open just means no persistence, never a
// failed run. --no-persist opts out entirely.
let store;
if (opts.persist) {
try {
// Seed the agent's intent (its prompt) so the dashboard's Runs list labels it instead of
// showing "(no prompt)". A build agent refines this via its scope event; a prompt/research
// run keeps it. Research with no "what" uses the same preset default the log title uses.
store = await AgentStore.open(cwd, {
fresh: true,
intent: intent || (opts.research ? defaultWhat() : ''),
// Adopt the daemon's id (#736) so the agent and the worktree it lives in share one.
...(opts.agentId ? { id: opts.agentId } : {}),
// Continuing (#762): keep the existing log rather than starting this agent's history over.
...(opts.continueAgent ? { continueAgent: true } : {}),
// The flow this agent starts under (#1467), so a later continuation can re-enter it. A
// continuation itself keeps the prior meta's record — this seed only lands on a fresh agent.
kind: agentLogKind(opts, transparent) === 'build' ? 'build' : 'prompt',
// Where the agent executes (#1053): the agent view reads it to switch to the Actions affordance.
...(opts.target ? { target: opts.target } : {}),
});
}
catch (err) {
io.err(`could not persist session state (${errorMessage(err)}); continuing without it`);
}
}
// Continuing a build agent (#1467): the composer's Resume always arrives as a `prompt` start,
// but the reopened meta remembers the flow the first leg ran. When it was a build agent and the
// conversation is resumable, re-enter the build flow (synthesize framing, backlog loop, the
// build ending) with the message sent verbatim — not the bare prompt ending that used to
// downgrade a resumed build agent. Runs from before the meta recorded a kind stay on the prompt
// path, exactly as they did.
const continueBuild = opts.continueAgent === true && !!opts.resumeSession && !transparent && store?.snapshot().kind === 'build';
// Steer this agent through .the-framework/control.jsonl (#344): a Stop button or choice
// pick appends an entry, we tail the file and abort / resolve the parked gate. Reset
// first so a previous agent's picks can never fire into this one (gate ids repeat across
// runs).
//
// Wired when this agent's own dashboard is up (#427), or when whoever spawned it passed --run-id
// and therefore steers it (see {@link isSteerable}).
// What this session hands back when it ends (#1102). Mutable: the action bar's checkboxes can
// move it at any point up to the moment it settles, which is the whole point of them being
// pre-commitments rather than buttons. One rung, not three flags (B5), so the stages cannot
// disagree — every reader derives them, and a box unticked mid-run lowers the whole ladder
// instead of leaving a merge armed with no PR under it.
// The one thing that raises it after this line is the user's own Merge action (#1391), which
// also records the human authorization below.
let armedHandoff = config.handoff;
// The user pressed Merge (#1391): a human authorized the merge, so the human-authorized merge gate (#1363) must not also
// demand the agent's ready-for-merge signal — a human's word outranks it.
let mergeAuthorized = false;
// Assigned once the journal exists, which is after the control watcher is wired: a change that
// arrives before then still lands on `armedHandoff`, it just has no event to announce it yet.
let announceHandoff;
let control;
if (isSteerable(opts)) {
try {
await resetControl(cwd);
control = watchControl(cwd, entry => {
if (entry.kind === 'stop') {
controller.abort();
return;
}
if (entry.kind === 'message') {
messages.push(entry.text);
return;
}
if (entry.kind === 'handoff') {
armedHandoff = entry.level;
announceHandoff?.();
return;
}
if (entry.kind === 'merge') {
// The user's Merge action (#1391): arm the full ladder and record the human
// authorization. Not an abort — the session still ends at its own natural end (#1390)
// and the merge fires there; announcing re-arms keeps the meta a mid-run tab reads true.
armedHandoff = 'merge';
mergeAuthorized = true;
announceHandoff?.();
// A session parked on the backlog offer (#323) is waiting to know whether to take more
// work. Merge answers that: wrap up now. Other gates keep waiting — they are questions
// about the work itself, which merging does not answer.
for (const [id, resolve] of pendingChoices) {
if (!/^todo-next(-\d+)?$/.test(id))
continue;
pendingChoices.delete(id);
resolve({ picked: 'stop', by: 'user' });
}
return;
}
const resolve = pendingChoices.get(entry.id);
if (resolve) {
pendingChoices.delete(entry.id);
resolve({ picked: entry.pick, by: entry.by });
}
});
}
catch (err) {
io.err(`control channel unavailable (${errorMessage(err)}); daemon steering disabled`);
}
}
// Pause the choice gates when someone can answer: this agent's own dashboard, or the
// workspace daemon's via the control channel (#344). With neither, the gates auto-accept
// the recommended option (#304). The agent's requestChoice parks a resolver in pendingChoices
// keyed by the choice id; a dashboard/daemon pick (or an abort) resolves it.
// An unattended agent (#846) is steerable but unwatched: keep the control channel for Stop and
// live messages, and leave requestChoice unset so each gate takes its recommended option. Auto
// PM (#685) fires when nobody is there, and a parked gate would hang it until someone looked.
//
// Each parked wait is held by the keepalive (#1359): a daemon-spawned agent (--no-dashboard, all
// stdio detached, per-prompt driver children, unref'd control watcher) has nothing else ref'd
// between turns, so Node ran out of scheduled work and exited 0 mid-await — the gate's picks
// then landed in control.jsonl with nobody left to read them.
const gateKeepalive = createGateKeepalive();
const requestChoice = control !== undefined && !opts.unattended
? (req) => gateKeepalive.hold(new Promise(resolve => pendingChoices.set(req.id, resolve)))
: undefined;
// Whether to hand the agent the live-chat queue (#714) once it settles. Steerable is not enough
// (#905): a terminal agent with --no-dashboard could be reached by a daemon's dashboard, but
// nobody is waiting in it, and it used to park forever on a message that never came.
// The parked message wait is the same empty-event-loop hazard as a parked gate (#1359), so it
// is held the same way; a Stop closes the queue, which releases the hold.
//
// Who parks and who ends is #1390: a dashboard-spawned session drains what queued and then ends
// itself — the dashboard reopens the conversation as a continuation (#762), so nothing needs to
// stay alive for the composer.
const chatQueue = isInteractive(opts) && requestChoice
? {
messages: {
next: (signal) => gateKeepalive.hold(messages.next(signal)),
takeQueued: () => messages.takeQueued(),
},
...(opts.agentId === undefined ? { stayOpenChat: true } : {}),
}
: {};
// The session link shown on the dashboard: Claude Code's own entry for a live Claude agent,
// else nothing (#212/#542). Same for both agent paths.
const sessionLink = chooseSessionLink(opts, fake);
// Everything the agent reports that its epilogue needs — the settle flags, the deferred
// browser-port announcement — plus the fan-out of every event to the terminal and the store,
// lives in the journal. runCli reads its getters below.
const journal = createAgentJournal({ io, cwd, store, agentId: opts.agentId });
const onEvent = journal.onEvent;
// Put the armed state on the agent's meta (#1102), and keep it there as the checkboxes change it.
// The control channel carries the instruction, but only an event reaches meta, and meta is the
// only thing a dashboard tab opened mid-run can read the boxes back from.
// The event still spells the three stages out (#1382): it is a snapshot a dashboard tab reads
// back, and every armed line must carry the merge stage or the meta would say "draft PR" about a
// run that is set to merge. Derived from the rung, so the three can never contradict it.
announceHandoff = () => onEvent({ kind: 'handoff-armed', ...handoffStages(armedHandoff) });
announceHandoff();
// The ticket this agent implements (#1117), if the daemon named one. Once, at start: it is a fact
// about why the agent exists, not a state that changes, and folding it to meta is what lets the
// Overview mark that ticket as being implemented right now.
if (opts.ticket && isTicketPath(opts.ticket))
onEvent({ kind: 'ticket', path: opts.ticket });
// The branch this agent actually starts on (#1277): recorded, not guessed, so surfaces reading
// the meta mid-run resolve the same name teardown will later confirm. Undefined outside a git
// checkout (a non-repo project), and then nothing is recorded.
const startBranch = await currentBranch(cwd);
if (startBranch)
onEvent({ kind: 'branch', branch: startBranch });
// Fire the built-in on-before-mergeable (#326) prompt once a --on-before-mergeable agent has settled and the agent
// signalled setReadyForMerge(). Skipped for a fake/offline run and when the agent was stopped —
// and every outcome, including each skip, is emitted as an event so the dashboard can show it (#835).
const maybeFireOnBeforeMergeable = async () => {
// The step was never asked for, so there is no outcome to report and nothing to explain.
if (!opts.onBeforeMergeable)
return;
// Every other exit says so as an event (#835). stdout cannot carry this: a dashboard-started
// run is spawned with `stdio: 'ignore'`, so silence there read as "it ran and found nothing".
const skip = (reason) => onEvent({ kind: 'on-before-mergeable', outcome: 'skipped', reason });
if (!journal.sawReadyForMerge())
return skip('not-ready-for-merge');
if (journal.stoppedCleanly())
return skip('run-stopped');
if (fake)
return skip('fake-run');
// --eco-auto-maintenance (#314) no longer skips the whole agent: since #537 this prompt
// also carries `## Business knowledge`, which the flag does not name. It drops just
// `## Maintenance` inside renderOnBeforeMergeablePrompt() instead.
// Every line of the prompt names the session, so there is nothing to queue without one.
// An agent that made changes has one; this is the agent that ignored the instruction.
const sessionName = journal.sessionName();
if (!sessionName)
return skip('no-session-name');
const binPath = process.argv[1];
if (!binPath)
return skip('no-bin-path');
const outcome = await runOnBeforeMergeable(cwd, binPath, io, { session_name: sessionName });
onEvent({ kind: 'on-before-mergeable', outcome });
};
// Hand the session's work back (#1102): push the branch and open a draft PR for it, unless the
// action bar's checkboxes were unticked. Runs after the on-before-mergeable step above, so
// anything that step committed is part of what gets published rather than a commit left behind.
const maybeAutoHandoff = async () => {
const armed = handoffStages(armedHandoff);
const skip = (reason) => onEvent({ kind: 'handoff', outcome: 'skipped', reason });
if (!armed.push)
return skip('not-armed');
// A stopped agent is a session the user cut short; publishing what it happened to reach is the
// opposite of what stopping meant. Same call the on-before-mergeable step makes.
if (journal.stoppedCleanly())
return skip('run-stopped');
if (fake)
return skip('fake-run');
// The daemon commits whatever the agent left uncommitted, but only after this process exits
// (`tearDownWorktree`), so at this point the tree can still hold real work. Pushing first
// would publish a branch missing the session's last edits. Its own checkout only: a plain
// `framework "..."` runs in the user's tree, where committing for them is not ours to do.
//
// The result is load-bearing (#1376): a commit that failed here and was ignored let the
// handoff judge a branch missing the session's work — "committed nothing", skip — while the
// teardown's identical commit landed seconds later, stranding real work on a local branch
// nobody was told about. A failed commit is now its own skip, said out loud, and the
// teardown still rescues the work onto the branch afterwards.
if (opts.agentId && !(await commitPendingWork(cwd)))
return skip('commit-failed');
// The merge half is authorized, not just configured (#1363; rule settled on #1390): the
// agent's setReadyForMerge() — the same signal maybeFireOnBeforeMergeable requires above —
// is what says the work may land unattended, plus the session's own TODO file having no
// open entries. Never the global TODO_AGENTS.md: the queue is decoupled from sessions.
// Withheld is not skipped — push and PR go ahead, and with `armed.merge` off the PR opens
// as a draft for a human. Said on the handoff event (#835), or "auto-merge was on and
// nothing merged" has no answer.
// A human's Merge action (#1391) bypasses the gate: the authorization the gate exists to
// collect has been given directly, by someone who outranks the signal.
let mergeGate;
if (armed.merge && !mergeAuthorized) {
const ready = journal.sawReadyForMerge();
mergeGate = withheldMerge({
readyForMerge: ready,
agentTodoOpen: ready && (await agentTodoPending(cwd, journal.sessionName())),
});
if (mergeGate)
armed.merge = false;
}
// The branch as it is now, not as it was named at start: #326 lets the agent rename it, and
// the rename is exactly what the PR should be opened against.
const branch = await currentBranch(cwd);
if (!branch)
return skip('branch-gone');
const sessionName = journal.sessionName();
// The ticket's GitHub issue rides the PR title as `(fix #42)` (#1334): the squash-merge
// subject inherits the title, so the merge closes the issue — without it, an auto-merged
// quick-win leaves its ticket open. Not on a plan agent (#1327): its PR lands the plan, not
// the work, so the merge must not close the issue. Best-effort: a ticket that cannot be
// read fixes nothing. Read off the data branch (#1582) — the worktree holds no tickets.
const fixes = opts.ticket && isTicketPath(opts.ticket) && !opts.planAgent
? ticketIssueRef((await readDataFile(cwd, opts.ticket).catch(() => undefined)) ?? '')
: undefined;
// The agent's own description of the work (#1567), when it wrote one: this is what an
// `open-pr` block is for — the agent describes the change and the framework opens the PR,
// so it has no reason to run `gh pr create` itself and lose the title convention, the
// ticket's issue reference, and the recorded number along the way.
//
// A plan agent's description is defused first: its PR lands the plan, not the work, so a
// closing phrase in it would close the ticket's issue on merge — which is exactly what
// happened on #1560. The same reasoning already keeps `(fix #N)` off a plan agent's title
// just above; the description is the other half of the same rule.
const written = journal.pullRequest();
// Both halves are defused, not just the body: since #1618 the title is the agent's prose too,
// and a closing phrase there would ride the squash-merge subject straight into the issue.
const defuse = (text) => (text && opts.planAgent ? defuseClosingKeywords(text) : text);
const prTitle = defuse(written?.title);
const description = defuse(written?.description);
const agent = {
id: opts.agentId ?? '',
branch,
...(sessionName ? { sessionName } : {}),
...(intent ? { intent } : {}),
...(fixes ? { fixes } : {}),
...(prTitle ? { prTitle } : {}),
...(description ? { description } : {}),
};
const handedOff = await agentAutoHandoff(cwd, agent, armed);
const outcome = mergeGate && handedOff.outcome !== 'failed'
? { ...handedOff, merge: { outcome: 'withheld', reason: mergeGate } }
: handedOff;
onEvent({ kind: 'handoff', ...outcome });
// Record the PR itself (E6), so every later surface reads the number off the agent instead of
// re-deriving it from branch names and creation times.
if (outcome.outcome === 'done' && outcome.number !== undefined && outcome.url) {
onEvent({ kind: 'pull-request', number: outcome.number, url: outcome.url });
}
if (outcome.outcome === 'failed')
io.err(`✗ could not ${outcome.step === 'pr' ? 'open the PR' : 'push the branch'}: ${outcome.error}`);
else if (outcome.outcome === 'done' && outcome.url)
io.out(`\n◆ Opened ${outcome.url}`);
else if (outcome.outcome === 'done')
io.out(`\n◆ Pushed ${branch}.`);
// The merge half (#1216) rides on the outcome rather than being its own step: a merge that
// could not happen must not read as a failed handoff, the PR is there either way.
const merge = outcome.outcome !== 'failed' ? outcome.merge : undefined;
if (merge?.outcome === 'auto-armed')
io.out('◆ Auto-merge armed: the PR lands when its checks pass.');
else if (merge?.outcome === 'watched')
io.out('◆ Merge on green: the daemon merges the PR when its checks pass.');
else if (merge?.outcome === 'merged')
io.out('◆ Merged the PR.');
else if (merge?.outcome === 'withheld')
io.out(`◆ Merge withheld: ${mergeWithheldWhy(merge.reason)}.`);
else if (merge?.outcome === 'failed')
io.err(`✗ could not merge the PR: ${merge.error}`);
};
// The agent owns the browser (#793): launching it here, rather than letting chrome-devtools-mcp
// launch its own, is what lets the preview (#609) attach to the same page. Undefined when the
// machine has no Chrome, which leaves `--browser` on its old path rather than failing the agent.
// Local runs only: the browser tools are wired on this machine, so a `--run-on web`/`actions`
// session could never reach them — launching Chrome here would leak a headless browser per
// run, and the system channel must only claim a browser the agent really has (#824).
const localAgent = opts.target === undefined || opts.target === 'local';
const sharedBrowser = opts.browser && !fake && localAgent ? await launchSharedBrowser() : undefined;
if (opts.browser && !fake && !localAgent) {
io.err(`note: --browser has no effect with --run-on ${opts.target}: the browser tools are wired on this machine, and the session runs elsewhere.`);
}
else if (opts.browser && !fake && !sharedBrowser) {
io.err('note: no Chrome found, so --browser falls back to its own browser (no preview).');
}
/**
* Give up before a driver exists, on a config fault the agent cannot recover from.
*
* These aborts sit in an awkward window: `agent.json` already says `running` (it is written back
* at :1433), but {@link settleAgent} — which owns the `end` event and the handle cleanup — does
* not wrap anything until its `ctx` is built further down. Returning raw therefore left the agent
* recorded as `running` forever with nobody to correct it, and the dashboard showed a session
* that never moved. So close it out here the same way settleAgent would: say why, record the
* failure, release the handles.
*
* Best-effort throughout, like every other persistence call on this path: a store that cannot
* write its own failure must still let the process exit, which is the whole point of the fix.
*/
const abortBeforeDriver = async (reason) => {
io.err(reason);
// Release every handle settleAgent would: the armed interrupt trap, the agent's own dashboard
// server, and the open event store — leaving any of them behind kept the process alive with a
// swallowed first Ctrl+C, exactly the hang this helper exists to prevent.
clearInterrupt();
try {
await store?.append({ kind: 'end', ok: false, detail: reason });
await store?.close();
}
catch {
// Persistence is never allowed to be the thing that keeps a failing agent alive.
}
control?.close();
await sharedBrowser?.close().catch(() => { });
return 2;
};
// Run on GitHub Actions (#1050): owner/repo come from the project's origin remote, the token from
// the environment or, failing that, the `gh` CLI (#1352) — never the committed the-framework.yml,
// since a repo file is public and this must be a user credential. Resolved before the driver so a
// missing remote or token fails the agent with a clear reason rather than deep inside ActionsDriver.
let actionsConfig;
if (opts.target === 'actions' && !fake) {
const slug = await githubSlugFor(cwd);
const token = await githubToken(cwd);
if (!slug) {
return await abortBeforeDriver('--run-on actions needs a GitHub origin remote on this repo.');
}
if (!token) {
// Both ways out, because neither is guessable from the other: the env var is what CI sets,
// and `gh auth login` is what a laptop has. Says which process needs it, too — the daemon
// hands each agent its own environment, so exporting the variable in a shell does nothing for
// a daemon that is already up.
return await abortBeforeDriver('--run-on actions needs a GitHub user token (repo + workflow scopes): set GH_TOKEN in the environment ' +
'the daemon runs in, or log in with `gh auth login`. It must belong to a user, not an App — the agent ' +
'workflow refuses a bot-triggered run.');
}
actionsConfig = { owner: slug.owner, repo: slug.repo, token };
io.out(`◆ run on: GitHub Actions (${slug.owner}/${slug.repo})`);
}
// Run on Claude Code on the web (#610). Nothing to resolve: the CLI holds the account the
// cloud session is created under, so there is no token of ours and no repo config. The
// session clones this repo's remote at its current branch, so local commits that were never
// pushed are not in it — say so once here rather than let the cloud session look stale.
if (opts.target === 'web' && !fake) {
io.out('◆ run on: Claude Code on the web (a cloud session on your own account)');
if (!(await githubSlugFor(cwd))) {
io.out(' no GitHub remote here, so the CLI uploads a bundle of this repo instead.');
}
}
const driver = fake
? fakeDriver()
: createAgentDriver({
driver: opts.driver,
claudeOpts: withBrowser(claudeOpts, opts.browser, sharedBrowser?.browserUrl),
...(opts.target ? { target: opts.target } : {}),
...(actionsConfig ? { actionsConfig } : {}),
});
// Whether the agent actually ends up with browser tools, which is narrower than the flag: they
// ride Claude Code's MCP config, so `--browser` on another agent wires nothing (see
// `unguardedNotices`), the fake driver has no tools at all, and a remote target never sees
// this machine's MCP config. The system channel must only claim a browser the agent really has (#824).
const browserAttached = opts.browser && !fake && opts.driver === 'claude' && localAgent;
// The preview of that browser (#802): the agent's Chrome is headless, so when it parks on an
// browser hand-off gate (#796) there is nothing for a human to click. This serves it. Opening
// the stream costs nothing while the page is still — Chrome only emits a frame on a change.
const browserStream = sharedBrowser
? await startBrowserStream({
browserUrl: sharedBrowser.browserUrl,
connect: connectCdp,
// Every real page the preview shows lands in the transcript (#1455 item 6b), so the
// inline pane appears at the point of use rather than only in the rail.
onPage: url => journal.announceBrowserUrl(url),
}).catch(() => undefined)
: undefined;
// The port travels as an event — persisted and published live — because a dashboard-started
// run is spawned with its stdout discarded, so a printed URL reaches nobody (#813). The
// journal holds it until the session opens: the bridge exists before the agent does, and an
// event ahead of `session` never reaches the dashboard (#829).
if (browserStream)
journal.announceBrowserPort(browserStream.port);
// Nothing gates a session that is already running (E1). One gate decides whether a session may
// *start* — the daemon's quota boundary, fed by the same slider the Usage bar shows — and once
// it has, the session runs to its own end. Interrupting mid-flight is the worst moment to
// economise: the tokens are already spent, the work is half-done, and what is saved is the cheap
// part while what is lost is the expensive part.
if (transparent)
io.out(`◆ transparent: on — raw ${DRIVER_SPECS[opts.driver].label}, no framework prompt, dashboard, or TODO loop`);
// A user SYSTEM.md + the anti-lazy-pill toggle shape the system prompt (#301), and the eco
// flags trim the built-in one (#314). Resolve and echo once, shared by both agent paths.
const promptConfig = await resolvePromptConfig(opts, config, cwd, io, transparent);
// The run-scoped state both agent paths hand to settleAgent to close out. stoppedCleanly is a
// getter because the onEvent sink sets it as the `end` event arrives.
const epilogue = (failLabel) => ({
io,
store,
control,
sharedBrowser,
browserStream,
clearInterrupt,
maybeFireOnBeforeMergeable,
maybeAutoHandoff,
isStopped: () => controller.signal.aborted || journal.stoppedCleanly(),
failLabel,
});
// The options both agent paths pass through unchanged: who the agent is, what it may spend, what
// it reads, and who can answer it. They were written out twice, thirteen conditional spreads
// each, so a new one had to be added to both by hand — and an agent started as a build and an agent
// started as a prompt are the same agent in every respect but the scaffolding around the prompt.
const sharedAgentOptions = {
driver,
cwd,
onEvent,
signal: controller.signal,
...(requestChoice ? { requestChoice } : {}),
...chatQueue,
...(opts.model ? { model: opts.model } : {}),
...(promptConfig.userSystemPrompt ? { systemPrompt: promptConfig.userSystemPrompt } : {}),
...(promptConfig.noBuiltinPrompt ? { vanilla: true } : {}),
...(browserAttached ? { browser: true } : {}),
...(transparent ? { transparent: true } : {}),
...(opts.context.length ? { context: opts.context } : {}),
...(sessionLink ? { sessionLink } : {}),
};
// Which prompt opens the session, and whether its backlog is worked afterwards (D2). A `prompt`
// session runs its text verbatim — it may already BE an edited preset, so it must not be
// re-rendered — and research renders its preset template around the "what" first. Transparent
// (#625) joins them: "raw Claude Code" must bypass the build framing too, not just zero the
// system prompt. A build continuation (#1467) stays a build despite arriving as a `prompt`
// start: the flow it re-enters is the one recorded on its own meta.
const isResearch = opts.research && !transparent;
const kind = (opts.research || opts.directPrompt || transparent) && !continueBuild ? 'prompt' : 'build';
const label = kind === 'build' ? 'session' : isResearch ? 'research' : 'prompt session';
const agentOpts = {
...sharedAgentOptions,
kind,
...(opts.target ? { location: opts.target } : {}),
prompt: isResearch ? presets.research.render(intent) : intent,
// Resume the stopped leg's conversation (#720/#1467); the prompt above is the continuation
// message, which `runAgent` then sends verbatim.
...(opts.resumeSession ? { resumeSessionId: opts.resumeSession } : {}),
...(opts.todoLoop && !transparent ? {} : { todoLoop: false }),
};
return settleAgent(epilogue(label), async () => {
await runAgent(agentOpts);
// A hand-off ends at the hand-off (#1225): "done" would claim this machine built something
// it never saw.
const successLine = isHandsOff(opts.target)
? '\n✓ handed off. The session continues where it was sent, and opens its own pull request.'
: isResearch
? '\n✓ research done: see the REVIEW-PROBLEMS / TODO files it wrote.'
: kind === 'prompt'
? '\n✓ prompt session done.'
: '\n✓ done.';
return { successLine };
});
}
/**
* 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 — along with every session it is
* running, which is the only mode there is. Blocks until the server is signalled (SIGINT/SIGTERM).
*/
async function runForegroundDaemonCmd(args, io) {
const cwd = process.cwd();
const port = args.port ?? DEFAULT_DAEMON_PORT;
const host = args.host;
// #1051: pre-generate the shared token for a non-loopback bind so onListening (sync) can print
// the reachable URL; runDaemon reuses the same persisted token.
const token = host !== undefined && !isLoopbackHost(host) ? await ensureDaemonToken() : undefined;
try {
await runDaemon(cwd, {
port,
...(host !== undefined ? { host } : {}),
onListening: state => {
io.out(`◆ dashboard running: ${state.url}`);
if (!isLoopbackHost(state.host ?? DEFAULT_DAEMON_HOST)) {
printNonLoopbackAccess(io, state.host ?? DEFAULT_DAEMON_HOST, state.url, token);
}
io.out(' Ctrl+C to stop the dashboard and every session it is running. Server logs stream below.');
// #312 asks bare `framework` to print the commands + version too. onListening is sync and
// runDaemon then blocks until signalled, so this is fire-and-forget by necessity: the
// update line lands a moment later, above the server logs.
void printStartupFooter(io);
},
});
}
catch (err) {
io.err(`could not start the dashboard (${errorMessage(err)}).`);
return 1;
}
return 0;
}
/**
* The loud one-line warning and the token-bearing URL printed on any non-loopback daemon bind
* (#1051). A daemon that spawns processes is code execution for anyone who reaches the port, and
* the shared token is the only guard, so this says so before the daemon is left running. The bound
* host is a bind-all like `0.0.0.0`, so the user swaps it for the machine's actual reachable
* address (a Tailscale/LAN hostname); the token rides the URL for the first hop, then the cookie.
*/
function printNonLoopbackAccess(io, host, url, token) {
io.err(`⚠ SECURITY: bound to ${host} (non-loopback). This exposes code execution to your network; the shared token is the only guard (#1051).`);
if (!token)
return;
io.out(` Open with the token (swap ${host} for this machine's reachable address, e.g. a Tailscale hostname):`);
io.out(` ${url}/?token=${token}`);
}
/**
* The startup footer every dashboard path prints (#312): where prompts come from, the version,
* and then — once npm answers — whether that version is the latest.
*
* The update line is deliberately not awaited before the static lines. #312 asks for the static
* info first, and the foreground path (bare `framework`) blocks on the server forever, so a line
* printed after the await would never appear there at all. `checkForUpdate` is already forgiving:
* offline or slow (2.5s cap) resolves to 'unknown', which prints nothing.
*/
export function printStartupFooter(io, opts = {}) {
const version = frameworkVersion();
io.out('');
io.out('Type a prompt on the dashboard to start an agent, or use:');
io.out(' framework --help All options');
io.out('');
io.out(`The Framework v${version}`);
return checkForUpdate(version, opts.fetchLatest ?? nodeVersionFetcher())
.then(status => {
const line = formatUpdateStatus(status);
if (line)
io.out(line);
})
.catch(() => { });
}
/**
* The session spec a spawned on-before-mergeable child runs with (D4). Pure so a test can assert
* it: note it carries **no** `onBeforeMergeable`, which is the recursion guard — a quality pass
* must not trigger its own suite.
*/
export function promptAgentSpec(prompt, cwd, vanilla = false) {
return {
prompt,
kind: 'prompt',
cwd,
options: {
// The on-before-mergeable follow-up runs vanilla so it skips the built-in system prompt (#326)'s `### Session
// name` step: it is a follow-up to a session, not a session of its own, so it must not
// commit + branch + checkout a new `the-framework/<name>` branch. That stranded its output
// (the #556 TODO entries and #537 knowledge docs) on a branch nothing merges (#560). Vanilla
// keeps it on the session's current branch, where its output rides to review and merge with
// the work. The follow-up prompt is self-contained.
...(vanilla ? { vanilla: true } : {}),
},
};
}
/**
* Run one direct prompt by spawning `framework --agent <spec>`, reusing the whole agent path
* (preflight, driver, budget cap, session archive). The child inherits stdio so its agent streams to the
* terminal. Note the spec carries no `onBeforeMergeable`, so a quality pass never triggers its own
* on-before-mergeable prompt (the recursion guard). Resolves true on a clean exit (0). Never
* re-execs a test entry (fork-bomb guard).
*/
async function spawnPromptAgent(prompt, cwd, binPath, vanilla = false) {
if (process.env.NODE_TEST_CONTEXT || /\.test\.[cm]?[jt]s$/.test(binPath)) {
return false; // refuse to spawn from a test entry
}
const specPath = await writeAgentSpec(promptAgentSpec(prompt, cwd, vanilla));
return new Promise(resolvePromise => {
const child = spawn(process.execPath, [binPath, '--agent', specPath], { stdio: 'inherit' });
child.once('error', () => {
// The child never ran, so nothing consumed the spec: remove it here or the prompt stays on disk.
void removeAgentSpec(specPath);
resolvePromise(false);
});
child.once('exit', code => {
// A child that died before reading its spec leaves the prompt on disk; one that consumed
// it makes this a no-op.
void removeAgentSpec(specPath);
resolvePromise(code === 0);
});
});
}
/**
* Fire the built-in on-before-mergeable (#326) prompt after an agent signalled setReadyForMerge(): one
* `framework prompt` child on the same workspace that appends the quality follow-ups to the
* session's TODO file, for the backlog loop (#323/#538) to pick up.
*
* It used to run maintainability, readability and security-audit inline instead, as three
* child runs back to back (#556). Queueing is both what the doc says and the cheaper thing:
* one short turn that writes a few TODO lines, rather than three full preset passes serialized
* on the same git index. Best-effort, like the suite was: a failure is logged, never thrown.
*
* Returns how it went so the caller can emit it as an event (#835); the `io` lines stay for
* a terminal agent, which is the one surface that can still read them.
*/
export async function runOnBeforeMergeable(cwd, binPath, io, tf,
// Vanilla by default: the follow-up must not run the session-name step and branch (#560).
agent = (prompt, cwd, binPath) => spawnPromptAgent(prompt, cwd, binPath, true), fs = nodeStoreFs()) {
// Ensure the presets exist so the queued entries' filePaths resolve, even in a repo
// activated before they shipped or a fresh clone (they are gitignored) (#598). Best-effort,
// like the rest of this agent: a materialize failure must not block the queueing.
try {
await materializePresets(cwd, fs);
}
catch (err) {
io.out(` ! on-before-mergeable: could not materialize presets (${errorMessage(err)})`);
}
io.out(`\n◆ on-before-mergeable: queueing quality follow-ups for ${tf.session_name}`);
const ok = await agent(renderOnBeforeMergeablePrompt(tf), cwd, binPath);
if (!ok)
io.out(` ! on-before-mergeable queueing did not complete cleanly.`);
return ok ? 'queued' : 'incomplete';
}
//# sourceMappingURL=cli.js.map