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.
248 lines • 14.7 kB
JavaScript
import { mkdir } from 'node:fs/promises';
import { basename, join, relative, isAbsolute } from 'node:path';
import { FRAMEWORK_DIR, reconcileOrphanedAgents } from './store/index.js';
import { startDashboard } from './dashboard/index.js';
import { createProjectRuntime } from './daemon-runtime.js';
import { defaultQuotaSource } from './dashboard/quota.js';
import { startBackgroundServices } from './daemon-services.js';
import { projectErrorStore } from './project-errors.js';
import { resolveDashboardBundle } from './dashboard/bundle.js';
import { isActivated } from './project.js';
import { addProject, ensureDaemonToken, listProjects, nodeRegistryFs, readPreferences, registryPreferencesStore } from './registry.js';
import { registryDiscordCredentialsStore } from './discord-credentials-store.js';
import { JsonlTailer } from './jsonl-tail.js';
import { isLoopbackHost } from './loopback-host.js';
import { bridgeSessionsFrom } from './dashboard/bridge-sessions.js';
import { readAllAgents } from './store/index.js';
/**
* The dashboard process (#302). It is a pure projection of the store: a session appends its
* events to `.the-framework/events.jsonl`, and the dashboard *tails* that file, pushing each
* new event to connected browsers. No session<->dashboard IPC — the file is the seam, matching
* "the dashboard is a projection of the event stream". Steering goes the other way through
* `.the-framework/control.jsonl` (#344).
*
* It runs in the foreground and only in the foreground: Ctrl-C closes the dashboard and every
* session it is running. There is no detached mode, so there is no liveness record, no
* machine-global state file, and no second process to find, reuse or stop.
*
* Sessions and steering are keyed per project: it spawns each session with `--cwd <project
* path>` and appends its control entries to that project's own `control.jsonl`. Its own `cwd`
* is just the home project it streams by default.
*/
/** The default dashboard port the daemon binds. */
export const DEFAULT_DAEMON_PORT = 4200;
/** The default bind host (#1051): localhost only, so the daemon is unreachable off the machine. */
export const DEFAULT_DAEMON_HOST = '127.0.0.1';
/**
* True when `host` is a loopback address the browser reaches without leaving the machine (#1051).
* Defined in its own leaf module so the dashboard's RPC mount can share the one definition without
* importing this one back (a cycle); re-exported here for the callers that already had it.
*/
export { isLoopbackHost };
/** The `.the-framework/` directory for a workspace. */
function daemonDir(cwd) {
return join(cwd, FRAMEWORK_DIR);
}
/** True when `child` lives strictly inside `parent` (not equal, not outside). */
export function isNestedWithin(child, parent) {
const rel = relative(parent, child);
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
}
/**
* Make sure an activated home workspace shows up in the Projects list (#392). Best-effort
* and idempotent (addProject dedupes by path), so it never blocks the daemon coming up.
*
* Skips a cwd that lives inside an already-tracked project (#647): the daemon creates
* `.the-framework/` for its own state, so running it from a subfolder of a repo (e.g. the
* package dir the binary lives in) would otherwise keep re-adding a nested duplicate.
*/
export async function registerHomeProject(cwd, env = process.env) {
if (!(await isActivated(cwd).catch(() => false)))
return;
const existing = await listProjects(undefined, env).catch(() => []);
if (existing.some(p => isNestedWithin(cwd, p.path)))
return;
await addProject(cwd, new Date().toISOString(), undefined, env).catch(() => { });
}
/** True when a process with this id is still running (best-effort, signal 0). The store's
* {@link isPidAlive} under the daemon's historical public name -- the two were byte-identical. */
export { isPidAlive as isProcessAlive } from './store/index.js';
/**
* Tails the append-only `.the-framework/events.jsonl` agent log. The generic tailing
* lives in {@link JsonlTailer}; this keeps the event-typed name the daemon (and
* public API) always had.
*/
export class EventTailer extends JsonlTailer {
}
/**
* The daemon body, run in the foreground by bare `framework`. Serves the built dashboard bundle
* (#405/#426): the SPA reads each project's `.the-framework/events.jsonl` over an event stream and
* steers over control.jsonl, so the daemon just serves the files and spawns sessions. Resolves on
* SIGINT/SIGTERM after tearing the dashboard down.
*/
export async function runDaemon(cwd, opts = {}) {
const port = opts.port ?? DEFAULT_DAEMON_PORT;
const host = opts.host ?? DEFAULT_DAEMON_HOST;
const env = opts.env ?? process.env;
// #1051: a non-loopback bind reaches the network, where a daemon that spawns processes is RCE for
// anyone who finds the port, so generate + persist the shared token the request guard requires. A
// loopback bind needs none, so the local zero-config path stays byte-identical.
const token = isLoopbackHost(host) ? undefined : await ensureDaemonToken(undefined, env);
// The browser bridge (#1237). Opt-in, because it opens the daemon's one route reachable from
// another origin. It reuses the same shared token (#1051) rather than minting a second one: the two
// guard the same daemon, so a second secret would be another thing to rotate and leak without
// narrowing anything. On a loopback bind that secret may not exist yet, hence ensure, not read.
const bridgeOn = (await readPreferences(undefined, env).catch(() => ({}))).bridge === true;
const bridgeToken = bridgeOn ? await ensureDaemonToken(undefined, env) : undefined;
// Steering (#344): the daemon owns no run, so its Stop button and choice picks
// append to `.the-framework/control.jsonl`; the live agent tails that file. Appends
// are best-effort — a full disk must not take the dashboard down with it.
// The event/control logs and the fs.watch all live under `.the-framework/` — create
// it up front so the daemon works as the very first command in a fresh workspace
// (before any run made the dir).
await mkdir(daemonDir(cwd), { recursive: true });
// Multi-project (#392): make sure an activated home repo shows up in the Projects list.
await registerHomeProject(cwd, env);
// Crash recovery (#642): a fresh daemon drives no in-flight run, so any run a dead
// process left marked `running` is orphaned — it would show as active forever with a
// no-op Stop. Reconcile them to `stopped` across every registered project at boot.
for (const record of await listProjects(undefined, env).catch(() => [])) {
const fixed = await reconcileOrphanedAgents(record.path).catch(() => 0);
if (fixed > 0)
console.log(`[framework] reconciled ${fixed} orphaned agent(s) in ${basename(record.path)}`);
}
// Everything the dashboard drives per project — run spawning, project install, and app
// previews — lives in the runtime, so this body stays about the daemon's own lifecycle.
const runtime = createProjectRuntime({
cwd,
env,
...(opts.binPath !== undefined ? { binPath: opts.binPath } : {}),
...(opts.driverPreflight !== undefined ? { driverPreflight: opts.driverPreflight } : {}),
});
// The daemon serves the built dashboard bundle (#405/#426): the SPA reads each project's
// `.the-framework/events.jsonl` over `GET /_rpc/events` and steers over control.jsonl, so there
// is no in-process event stream to feed here. The runtime's RPCs reach the browser through the
// dashboard context the mount is wired with. A missing bundle (a broken install) surfaces as a
// 503 from the server.
const clientBundleDir = await resolveDashboardBundle();
// Owned here rather than left to the dashboard (#685): auto PM has to consult the same
// long-lived meter the usage panel draws, and a second poller would double a rate-limited read.
const quota = defaultQuotaSource();
// The per-project error state (#1500): the background services write it, the dashboard reads it.
const projectErrors = projectErrorStore();
// Assigned below, read from the credentials store's `onChange` (#1095): the dashboard mount has
// to exist before the services do, and a save can only arrive over a mount that is already up.
let services;
const dashboard = await startDashboard({
host,
port,
quota,
onStart: runtime.onStart,
onAddProject: runtime.onAddProject,
// Relay an agent to/from a connected device (#1067): the events source streams an agent this daemon
// is relaying, `remote` lets the read RPCs forward a remote agent's reads/steer/push to its device
// (slice 2), and the `/_relay/*` endpoints let another daemon run + read + steer a session here.
eventsSource: runtime.remoteEventsSource,
remote: runtime.remoteAgents,
relay: { tailEvents: runtime.tailRelayEvents, rpc: runtime.onRelayRpc },
// The browser bridge (#1237): absent unless the preference is on, which 404s every route.
...(bridgeToken ? { bridgeToken, bridgeSessions: () => listBridgeSessions(env) } : {}),
// Configure Discord from the dashboard (#1095). `onChange` is the half that makes the step
// finishable in-product: the credential is written to the registry, then this daemon's own
// Discord services are rebuilt against it, so the bot connects without a restart.
discord: registryDiscordCredentialsStore({ env, onChange: () => services?.reloadDiscord() }),
// Same idea for "Spend what's left on the roadmap" (#1161): the sweep re-reads the preference
// per tick, so without this the box you just ticked sits there doing nothing for up to ten
// minutes and reads as broken. Only on the write that switches it *on* — an unrelated setting
// saved while it happens to be on is not a reason to go spend quota.
preferences: registryPreferencesStore(nodeRegistryFs(), env, written => {
if (written.autoPm === true)
void services?.wakeAutoPm();
}),
// Only the daemon runs the sweep, so only it can say what the sweep decided.
autoPm: () => services?.autoPmReport(),
// ...and only it can be asked to sweep now (#1210). On demand, not the plain wake the
// switched-on preference above uses: the button is an explicit ask, so the sweep runs even
// while auto-run is off — one sweep for the click, and the schedule stays off.
autoPmSweep: opts => services?.wakeAutoPm({ onDemand: true, ...opts }),
projectErrors: projectErrors.list,
...(token ? { token } : {}),
...(clientBundleDir ? { clientBundleDir } : {}),
});
try {
const actualPort = Number(new URL(dashboard.url).port) || port;
opts.onListening?.({ pid: process.pid, port: actualPort, host, url: dashboard.url, startedAt: new Date().toISOString() });
}
catch (err) {
// Startup failed after the port was bound. Tear the server down, or it keeps the event loop
// alive: a zombie process squatting the port.
await dashboard.close();
throw err;
}
// Every background start is a verbatim prompt agent (#353): these are preset prompts and chat
// text, not build intents to scaffold from.
const startAgent = (prompt, options, id) => runtime.onStart(prompt, 'prompt', options, id);
// Everything that runs in the background beside serving the dashboard: the Discord watchers,
// auto PM, and the conversation committer.
//
// Nothing is resumed at boot. Ctrl-C closed the last dashboard and every session it was running,
// and that was a deliberate act — starting those sessions again behind the user's back is not
// what they asked for. A stopped session keeps its worktree and branch, so it is theirs to
// continue from the dashboard whenever they want it.
services = startBackgroundServices({
cwd,
env,
dashboardUrl: dashboard.url,
quota,
startAgent,
activeAgentCount: runtime.activeAgentCount,
busyAgentIds: runtime.busyAgentIds,
projectErrors,
log: console.log,
});
await waitForShutdown(opts.signal);
// Nothing may start or steer an agent from here on, so the background services go first (#923):
// auto PM or a Discord message arriving mid-shutdown would start one while we stop the rest.
await services.quiesce();
// Stop the agents this daemon spawned, before the previews they may be serving. Left running they
// are orphans nothing tracks; stopped here they keep their worktree and branch, so the dashboard
// can continue them on the next start.
const stopped = await runtime.stopAgents().catch(() => 0);
if (stopped > 0)
console.log(`[framework] stopped ${stopped} agent(s)`);
// Archives need no flush pass here (#1582): teardown writes each one through the data branch's
// funnel, committed and pushed the moment the session settles.
// Stopped here as well as by the dashboard: a broken install serves 503s without ever taking
// ownership of the source we handed in, and that poller would go on reading by itself.
quota.stop();
await runtime.dispose();
await dashboard.close();
}
/** Resolve on SIGINT/SIGTERM, or when the optional abort signal fires. */
function waitForShutdown(signal) {
return new Promise(resolvePromise => {
if (signal?.aborted)
return resolvePromise();
const done = () => {
process.off('SIGINT', done);
process.off('SIGTERM', done);
signal?.removeEventListener('abort', done);
resolvePromise();
};
process.once('SIGINT', done);
process.once('SIGTERM', done);
signal?.addEventListener('abort', done, { once: true });
});
}
/**
* The cloud sessions the browser bridge should have a tab open for (#1237).
*
* Across every registered project, because a cloud agent is not tied to the daemon's home
* checkout, and best-effort per project so one unreadable repo cannot empty the list.
*/
async function listBridgeSessions(env) {
const projects = await listProjects(undefined, env).catch(() => []);
const agents = (await Promise.all(projects.map(p => readAllAgents(p.path).catch(() => [])))).flat();
return bridgeSessionsFrom(agents, new Date());
}
//# sourceMappingURL=daemon.js.map