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.
807 lines • 47.6 kB
JavaScript
import { spawn } from 'node:child_process';
import { closeSync, mkdirSync, openSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
import { appendFile, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { agentIdFromStartedAt, startedAtFromAgentId, addWorktree, agentBranchName, linkDependencies, excludeDependencyLinks, archiveWorktreeAgent, restoreArchivedAgent, attachWorktree, worktreePath, listAgents, findAgent, archivedAgentPaths, currentBranch, removeWorktree, pruneWorktrees, resolveAgentEventsPath, FRAMEWORK_DIR, EVENTS_FILE, META_FILE, isPidAlive, } from './store/index.js';
import { agentIdFromWorktreeDir } from './branch-names.js';
import { removeAgentSpec, writeAgentSpec } from './agent-spec.js';
import { RelayedAgents, startRemoteAgent } from './dashboard/remote-run.js';
import { agentBranchFor } from './dashboard/agent-handoff.js';
import { dispatchRelayRpc } from './dashboard-rpc/relay-dispatch.js';
import { tailAgentEvents } from './dashboard-rpc/events-tail.js';
import { resolveUserDir } from './agent-archive.js';
import { withDataBranch } from './data-branch.js';
import { removeProjectWorktree } from './worktrees.js';
import { reconcileBranchLinks } from './branch-links.js';
import { scopedKey, parseScopedKey, keyBelongsTo } from './runtime-keys.js';
import { addProject, listProjects, projectId } from './registry.js';
import { resolveProjectAgentOptions } from './daemon-services.js';
import { installProject, enumerateGitRepos } from './install.js';
import { isGitRepo, nodeGitRunner } from './project.js';
import { isCliTimeout } from './cli-exec.js';
import { withAgentLock } from './agent-locks.js';
import { errorMessage } from './error-message.js';
import { preflight, preflightProblems } from './preflight.js';
import { isDriverName } from './driver-names.js';
/**
* How long a passing agent preflight (#1326) is trusted before it is probed again. Short enough
* that logging out mid-session is noticed within a session's worth of starts, long enough that a
* burst of starts pays for the probes once.
*/
const DRIVER_READY_TTL_MS = 30_000;
/**
* The daemon's per-project business logic (#393/#736): spawning runs into worktrees, installing
* projects, and app previews, plus the spawn/terminate plumbing those need. Split from daemon.ts
* so that file reads as the daemon's lifecycle (state file, ports, boot, shutdown) and this reads
* as what the daemon does for a project -- the split createProjectRuntime's own doc always
* claimed, finished.
*/
/**
* Locate the CLI entry to re-invoke for a detached child, refusing to re-exec a test
* file. Under `node --test` (or a direct `node foo.test.js`) `process.argv[1]` is the test
* file, which re-runs the whole suite instead of the daemon/run body — and that suite calls
* back here, so each spawn spawns another: a fork bomb. A real agent passes the compiled bin
* (or an explicit `binPath`), so the guard only ever trips in tests.
*/
export function resolveSpawnBin(explicitBinPath) {
const binPath = explicitBinPath ?? process.argv[1];
if (!binPath)
throw new Error('cannot locate the framework CLI entry');
if (!explicitBinPath && (process.env.NODE_TEST_CONTEXT || /\.test\.[cm]?[jt]s$/.test(binPath))) {
throw new Error('refusing to spawn a framework process from a test entry; pass an explicit binPath');
}
return binPath;
}
/**
* Clean up after a `git worktree add` that was SIGTERMed mid-write (#997). Observed behavior: git
* removes its own administrative entry on the way out but leaves the partial checkout it had
* already written, so `git worktree prune` finds nothing to do and the directory stays.
*
* Only a timeout kill is cleaned up. Any other rejection may be git refusing a path that was
* already on disk before this agent asked for it, and that is not ours to delete.
*/
export async function cleanupTimedOutWorktree(repo, agentId, err) {
if (!isCliTimeout(err))
return;
await rm(worktreePath(repo, agentId), { recursive: true, force: true }).catch(() => { });
}
/** Best-effort append of a `log` event to an agent's live stream, so a daemon-side note surfaces on a
* run whose own process wrote every other line. Never throws. */
async function appendAgentLog(cwd, message) {
const event = { kind: 'log', message };
await appendFile(join(cwd, FRAMEWORK_DIR, EVENTS_FILE), JSON.stringify(event) + '\n').catch(() => { });
}
/** Spawn a detached, unref'd framework child (`node <binPath> --agent <specPath>`) that outlives us. */
export function spawnDetached(binPath, specPath, stderrFile) {
// stderr goes to a file, never a pipe: a detached child must not block on a dead parent's pipe
// buffer, and the file is what makes a silent boot death diagnosable (#1261). Best-effort — a
// run must still start when the log cannot be opened.
let fd;
if (stderrFile) {
try {
mkdirSync(dirname(stderrFile), { recursive: true });
fd = openSync(stderrFile, 'w');
}
catch { }
}
const child = spawn(process.execPath, [binPath, '--agent', specPath], {
detached: true,
stdio: ['ignore', 'ignore', fd ?? 'ignore'],
});
if (fd !== undefined)
closeSync(fd);
child.unref();
return child;
}
/** Where a spawned agent's stderr lands (#1261), so a child that dies at boot leaves a trace. */
export function agentStderrPath(cwd) {
return join(cwd, FRAMEWORK_DIR, 'stderr.log');
}
/** One line saying how a child ended, for the failed-start marker (#1261). */
function exitDetail(code, signal) {
return code !== null
? `its process exited with code ${code} before reporting anything`
: `its process was killed by ${signal ?? 'a signal'} before reporting anything`;
}
/**
* Leave a `failed` marker behind a child that died before writing its own lifecycle (#1261).
*
* A healthy agent's first act is opening its store (`agent.json` + `events.jsonl`); a child that
* exited without one never booted — a module resolution error being the observed case — and with
* stdio detached the crash went nowhere, so the session page polled "Waiting for the session to
* start" forever. The daemon's exit handler is the one place that knows, so it writes the minimal
* meta the page needs and surfaces the child's stderr tail in the agent log. A child that wrote its
* own meta is left alone: its lifecycle is its own to report.
*/
export async function markFailedStart(cwd, agentId, intent, detail) {
const metaPath = join(cwd, FRAMEWORK_DIR, META_FILE);
if (await stat(metaPath).then(() => true, () => false))
return false;
const now = new Date().toISOString();
const meta = {
status: 'failed',
id: agentId,
startedAt: startedAtFromAgentId(agentId) ?? now,
updatedAt: now,
...(intent.trim() ? { intent } : {}),
};
const stderrTail = (await readFile(agentStderrPath(cwd), 'utf8').catch(() => '')).trim().slice(-2000);
await mkdir(join(cwd, FRAMEWORK_DIR), { recursive: true }).catch(() => { });
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n').catch(() => { });
await appendAgentLog(cwd, `The session failed to start: ${detail}.` + (stderrTail ? `\n\n${stderrTail}` : ''));
console.log(`[framework] run ${agentId} failed to start: ${detail}`);
return true;
}
/**
* Driver deaths worth one more try (#1281): the connection dropped or the API buckled mid-run —
* failures about the transport, not the work. Conservative on purpose: an agent that failed for any
* reason this does not name stays failed, because retrying a real failure just re-runs it.
*/
const TRANSIENT_FAILURE = /connection closed|connection reset|connection error|econnreset|etimedout|socket hang up|overloaded|rate.?limit|api error: 5\d\d|internal server error/i;
/** Whether an agent's failure detail names a transient transport error (#1281). */
export function isTransientAgentFailure(detail) {
return detail !== undefined && TRANSIENT_FAILURE.test(detail);
}
/**
* The detail the agent's own `end` event failed with, off its archived event log (#1281), or
* undefined when the agent did not fail by its own report. Only a child-written `end` counts: a
* boot death (#1261) never writes one, and retrying an agent that cannot boot would just re-crash.
*/
export function lastAgentFailureDetail(eventsJsonl) {
let detail;
for (const line of eventsJsonl.split('\n')) {
if (!line.trim())
continue;
try {
const event = JSON.parse(line);
if (event.kind === 'end')
detail = event.ok === false ? event.detail : undefined;
}
catch {
// A malformed line is not this reader's problem; the events around it still count.
}
}
return detail;
}
/** How many times a transiently-dead agent is continued before its failure stands (#1281). */
export const MAX_TRANSIENT_RETRIES = 2;
/** The pause before a retry (#1281): long enough for a dropped connection to be worth re-trying. */
const TRANSIENT_RETRY_DELAY_MS = 15_000;
/** How long a continuation waits for its finished previous leg to exit and retire (#1529). */
const FINISHED_LEG_EXIT_GRACE_MS = 15_000;
/** What the continued session is told (#1281), in the resume (#923) prompt's shape. */
const RETRY_PROMPT = 'This session died to a transient connection error, not because anyone asked it to stop. Look at what you had already done, then carry on from there and finish the work.';
export function delay(ms) {
return new Promise(resolvePromise => setTimeout(resolvePromise, ms));
}
/**
* Stop a process and wait for it to actually go: SIGTERM, then SIGKILL if the grace period lapses.
* Returns whether it was alive to begin with.
*
* Both callers need the escalation for the same reason and had their own copy of it: a process
* that ignores SIGTERM (or wedges in shutdown) must not be left holding a port or a worktree.
*/
export async function terminate(pid, graceMs) {
if (!isPidAlive(pid))
return false;
try {
process.kill(pid, 'SIGTERM');
}
catch {
// exited between the check and the signal
}
if (!(await waitForExit(pid, graceMs))) {
try {
process.kill(pid, 'SIGKILL');
}
catch {
// raced us to exit
}
await waitForExit(pid, 1000);
}
return true;
}
/** Poll until the process is gone, or the timeout lapses. */
async function waitForExit(pid, timeoutMs) {
const step = 50;
for (let waited = 0; waited <= timeoutMs; waited += step) {
if (!isPidAlive(pid))
return true;
await delay(step);
}
return !isPidAlive(pid);
}
/**
* Wait until the daemon is finished with the agent slots it just stopped — the child gone *and* its
* teardown done — or the timeout lapses.
*
* Killing a pid is not letting go of the repo. The child's `exit` event lands a turn after the
* process disappears, and the teardown that event starts (archive the agent, commit its work, keep
* or remove its checkout) runs well past that. Without this wait, shutdown's archive commit fires
* while an agent is still being archived, and it misses that agent's ending (#912/#1179).
*
* A slot is settled when it is in neither map: `settle` drops it from `activeAgents` and parks its
* teardown in `retiring`, and the teardown clears itself on the way out. An agent with no worktree
* never enters `retiring` at all, and needs no special case — it is simply already gone from both.
*
* Bounded, because a wedged teardown must cost the shutdown its grace period, not the exit.
*/
export async function waitOutSlots(keys, slots, timeoutMs) {
const step = 25;
const deadline = Date.now() + timeoutMs;
const held = () => keys.filter(key => slots.activeAgents.has(key) || slots.retiring.has(key));
while (Date.now() < deadline) {
const outstanding = held();
if (outstanding.length === 0)
return;
// Await the teardowns that have started; poll for the slots whose `exit` has yet to land.
const parked = outstanding.flatMap(key => slots.retiring.get(key) ?? []);
if (parked.length > 0)
await Promise.all(parked.map(retired => retired.catch(() => { })));
else
await delay(step);
}
}
/**
* Wait out the previous leg of the agent a continuation is aimed at (#1529). A Resume clicked the
* instant an agent's row flips `done` can land while the child that wrote that ending is still
* mid-exit: the agent's slot then still holds a live pid, and the busy guard read "already active"
* off a session that is over by its own account — a spurious refusal the E2E settings story
* caught on a slow runner. A finished child's exit is imminent and its retirement is queued
* right behind it (see `retiring` in {@link createProjectRuntime}), so wait for both, bounded by
* `graceMs`, and let the reuse read a settled archive. A leg still calling itself `running` is a
* genuine collision: not waited on, so the guard's refusal stands.
*
* `readLegState` is asked until it commits, rather than sampled once (#1540). A leg's state is
* read off a `agent.json` its own process rewrites in place, so a single read can come back
* `unknown` for reasons that have nothing to do with the leg — a torn read, or the beat between
* the archive being written and the worktree going. Taking one such sample for "still running"
* skipped the wait entirely and handed the continuation to the busy guard mid-exit: #1529's
* refusal back as a rarer race, and the flake that sent this here. Only a leg that positively
* reports `running` short-circuits; `unknown` re-asks on the next tick, and the loop still ends
* the moment the slot clears, so the common path costs exactly one read as before.
*/
export async function waitOutFinishedLeg(key, slots, readLegState, graceMs) {
const occupied = () => slots.starting.has(key) || slots.activeAgents.has(key);
if (!occupied() && !slots.retiring.has(key))
return;
const deadline = Date.now() + graceMs;
let ended = false;
while (occupied() && Date.now() < deadline) {
// Settled at `ended`: the leg cannot un-finish, so the read is not repeated once it answers.
if (!ended) {
const state = await readLegState();
if (state === 'running')
return;
ended = state === 'ended';
}
await delay(25);
}
await slots.retiring.get(key)?.catch(() => { });
}
/**
* The daemon's per-project runtime (#393): the agent and preview state keyed by project id,
* plus the RPCs the dashboard invokes over `POST /_rpc/<name>`. A project runs any number of
* concurrent agents (each in its own worktree, #736) and one preview. The home `cwd` is the default target — a request
* with no project id (or the home id) resolves to it without a registry lookup. Split out of
* {@link runDaemon} so the daemon body reads as lifecycle and this reads as business logic.
*/
export function createProjectRuntime({ cwd, env, binPath, retryDelayMs, driverPreflight }) {
const homeId = projectId(resolve(cwd));
// Live run pids, keyed per agent rather than per project (#736) — see onStart for the key.
const activeAgents = new Map();
const starting = new Set(); // reserved keys mid-spawn, to close the async gap
// Set by the first stopAgents call and never cleared: the daemon shuts down once. A Start
// landing after the stop pass would spawn a detached agent outside the snapshot stopAgents
// terminates — an orphan on `ppid 1` nothing ever stops — because the HTTP surface closes
// after the agents do. Same shape as auto-pm's stop re-checks (#983).
let closing = false;
// A finished leg's exit → retirement chain, parked per agent slot so a continuation that raced
// the exit (#1529) can await the retirement instead of reusing a checkout mid-removal.
const retiring = new Map();
const parkRetirement = (key, retired) => {
retiring.set(key, retired);
void retired.finally(() => {
if (retiring.get(key) === retired)
retiring.delete(key);
});
};
// Runs this daemon is relaying to/from a connected device (#1067): the local half of a remote agent.
const relayedAgents = new RelayedAgents();
// The relayed-agent lookup the dashboard's read RPCs consult (#1067 slice 2): is this agentId remote, and
// which device owns it. Outlives the event stream so a finished remote agent's push/PR still reaches it.
const remoteAgents = {
target: agentId => relayedAgents.target(agentId),
list: projectId => relayedAgents.list(projectId),
};
// The device side of the relay (#1067 slice 2): run one whitelisted read/steer/handoff RPC against this
// daemon's own home checkout, for a daemon that relayed an agent here. Home id forces the addressed project.
const onRelayRpc = (fn, args) => dispatchRelayRpc(homeId, fn, args);
// A project id resolves to its repo path via the registry; the home id (or none)
// resolves to the daemon's own `cwd` without a lookup.
const resolveProject = async (id) => {
if (!id || id === homeId)
return cwd;
const records = await listProjects(undefined, env).catch(() => []);
return records.find(record => record.id === id)?.path;
};
/**
* Put a continued agent (#762) back in its own checkout: the same worktree if it was retained, else
* its own branch checked out fresh. Its archived history is restored into the checkout so the agent
* reopens its log rather than starting empty, which is what keeps it one row.
*
* The branch is the session's if the agent named one, else the run-id branch it started on.
* Returns undefined when none of that is possible, so the caller can fall back to a new agent.
*/
const continueWorkspace = (projectCwd, agentId) =>
// Under the same agent lock as teardown: a Resume clicked off a freshly-`done` run lands here
// while teardown is still archiving the very history this restores — reusing the checkout
// mid-retirement spawned the continuation into a tree about to be removed. Waiting the
// teardown out costs the click a beat and makes the reuse read a settled archive.
withAgentLock(worktreePath(projectCwd, agentId), async () => {
try {
const path = worktreePath(projectCwd, agentId);
const existing = await stat(path).then(s => s.isDirectory()).catch(() => false);
if (!existing) {
const archived = (await listAgents(projectCwd).catch(() => [])).find(agent => agent.id === agentId);
// The recorded branch first (#1277): an agent that branched itself (#326 allows it) has
// its work there, and re-attaching by the session-name guess would continue the agent on a
// branch without its previous commits.
const branch = agentBranchFor(archived ?? { id: agentId });
await attachWorktree(projectCwd, { agentId, branch });
await linkDependencies(projectCwd, path).catch(() => []);
}
await restoreArchivedAgent(projectCwd, path, agentId).catch(() => false);
return { cwd: path, agentId };
}
catch (err) {
console.log(`[framework] could not continue agent ${agentId} (${errorMessage(err)}); starting a new one`);
return undefined;
}
});
/**
* Whether the driver this agent picked can actually start (#1326), as one line to show when it
* cannot. `undefined` means go.
*
* Only the two targets that spend the local CLI are gated. An `actions` run executes on a
* GitHub Actions runner and drives it over the API, so a laptop with no `claude` on it starts
* that agent perfectly well; a `web` run is started *by* the local CLI under a pty, so it needs
* the binary and the login exactly as a local agent does. A `remote` run never reaches here,
* having been handed to its device further up.
*
* Only a *pass* is cached, and only briefly. Two probes cost around half a second, which is
* nothing against an agent but more than the window the one-at-a-time guard closes in, so paying
* it on every Start would make back-to-back starts race. Caching the failure instead would be
* the worse trade: logging in has to be picked up by the very next Start, not by a timeout or
* a daemon restart, so a broken setup is re-probed every time and costs only the user who
* already cannot run anything.
*/
const readyUntil = new Map();
const checkAgentReady = async (options) => {
if (options.target === 'actions')
return undefined;
const driver = isDriverName(options.driver) ? options.driver : 'claude';
if ((readyUntil.get(driver) ?? 0) > Date.now())
return undefined;
const result = await (driverPreflight ? driverPreflight(driver) : preflight({ driver }));
if (!result.ok)
return preflightProblems(result).join('; ');
readyUntil.set(driver, Date.now() + DRIVER_READY_TTL_MS);
return undefined;
};
/**
* The checkout an agent gets (#736). Each agent is given its own git worktree under the project's
* `.the-framework/branches/<branch name>` (#1580), on a `tf-agent-<agentId>` branch, so N runs on one
* repo never fight over the working tree — and the user's own checkout, uncommitted work
* included, is left untouched.
*
* A project that *structurally* cannot provide one — it is not a git repo — falls back to the
* main checkout, which is exactly the pre-#736 behavior, and keeps its pre-#736 limit of one agent
* at a time, since those agents *would* collide. Signalled by the absent `agentId`.
*
* A project that *is* a repo and whose `worktree add` failed does not fall back (#997): that
* downgrade silently pointed the agent at the user's own working tree, uncommitted work
* included, which is the one thing #736 exists to prevent. A `worktree add` on a large repo can
* outrun its budget and be SIGTERMed, so this is reachable in normal use, not just on a broken
* repo. The agent fails instead, because a failed agent is recoverable by starting it again and a
* checkout with agent edits mixed into it is not.
*/
const allocateWorkspace = async (projectCwd, agentId) => {
try {
const worktree = await addWorktree(projectCwd, { agentId, branch: agentBranchName(agentId) });
// `node_modules` is gitignored, so a fresh worktree has none: link the parent's in, and
// make git ignore the links (a `node_modules/` rule does not match a symlink, #738).
await linkDependencies(projectCwd, worktree.path).catch(() => []);
await excludeDependencyLinks(projectCwd).catch(() => { });
// The branches view (#1580) learns about this checkout now rather than at the next tick.
void reconcileBranchLinks(projectCwd).catch(() => { });
return { ok: true, workspace: { cwd: worktree.path, agentId } };
}
catch (err) {
if (await isGitRepo(projectCwd)) {
await cleanupTimedOutWorktree(projectCwd, agentId, err);
return { ok: false, error: `could not create a worktree for this run: ${errorMessage(err)}` };
}
console.log(`[framework] ${basename(projectCwd)} is not a git repository, so it gets no worktree; running in the main checkout`);
return { ok: true, workspace: { cwd: projectCwd } };
}
};
/**
* Retire a finished agent's worktree (#737). Its history lives inside the worktree, so it is
* copied into the repo first — otherwise removing the checkout would delete the agent from the
* dashboard's history.
*
* Then the retention rule: an agent that finished cleanly has nothing left to look at once its
* work is committed, so its worktree goes. An agent that failed or was stopped keeps its checkout,
* because that is exactly when you want to see the half-finished working tree and the diff it
* died holding. Those are removed explicitly (the dashboard's Remove), never silently on a timer.
*
* Best-effort from end to end: this runs off a process-exit event with nothing to return to,
* so a failure here must not take the daemon down.
*/
/** The project half of a preview key from a checkout: the registry id every preview RPC keys by. */
const projectKeyFor = (projectCwd) => projectId(resolve(projectCwd));
// Under the agent lock: a Push/Remove/Resume fired off a freshly-`done` meta lands in the daemon
// while this is mid-archive, and both sides commit in the same checkout. The loser used to
// report "could not commit the work this session left uncommitted" — or worse, this side lost
// and kept a worktree it should have removed. Serialized, whoever runs first commits the whole
// pending state (`add -A`) and the other side finds a clean tree and carries on.
const tearDownWorktree = (projectCwd, worktree, agentId) => withAgentLock(worktree, async () => {
try {
// Where the work ended up, recorded before the checkout can go (#799). The branch outlives
// the worktree and is the only handle the dashboard has left on a finished session.
const branch = await currentBranch(worktree);
// Filed under the identity this repo commits as, onto the data branch (#1179/#1582)
// through its write funnel: the archive is committed and pushed the moment it lands —
// durable without a human, and never a commit on main.
const user = await resolveUserDir(projectCwd);
const archived = await withDataBranch(projectCwd, `[The Framework] archive session ${agentId ?? agentIdFromWorktreeDir(basename(worktree))}`, async () => {
await archiveWorktreeAgent(worktree, projectCwd, undefined, branch, user);
});
if (!archived.ok && !archived.committed)
console.log(`[framework] could not archive session ${basename(worktree)}: ${archived.error}`);
// One rule (E5): the checkout goes once its work is on the remote, whatever state the agent
// ended in. `removeProjectWorktree` owns the whole sequence — commit what is pending, push
// the branch, remove only if the remote has it — so teardown, the sweep and the dashboard's
// Remove button are one behaviour. A push that cannot land keeps the checkout, and the
// sweep retries it later. It used to keep a failed or stopped run's checkout "for
// inspection", which meant those accumulated one per session until someone noticed.
// The directory's own name is the run branch, which carries the agent id (#1580), so this
// never depends on the caller having one.
const outcome = await removeProjectWorktree(projectCwd, agentId ?? agentIdFromWorktreeDir(basename(worktree)));
if (!outcome.ok)
console.log(`[framework] keeping worktree ${worktree}: ${outcome.error}`);
}
catch {
// A worktree we could not retire is a worktree left on disk, which is the safe direction.
}
});
// One more try for an agent the API dropped mid-work (#1281): the failure is about the transport,
// not the work, and the continue-agent machinery (#762/#923) reopens the retained checkout on its
// recorded branch (#1278). Counted in memory on purpose: a daemon restart already re-resumes
// runs (#923), and a lost count only ever grants one extra attempt.
const agentRetries = new Map();
const retryTransientDeath = async (projectCwd, targetProjectId, agentId, options) => {
const attempts = agentRetries.get(agentId) ?? 0;
if (attempts >= MAX_TRANSIENT_RETRIES)
return;
const meta = (await listAgents(projectCwd).catch(() => [])).find(agent => agent.id === agentId);
// Only a run that failed by its own report, and only a local one: a web/actions run's
// lifecycle lives elsewhere and is not this daemon's to replay. A stopped agent stays stopped.
if (meta?.status !== 'failed')
return;
if (meta.target !== undefined && meta.target !== 'local')
return;
const jsonl = (await archivedAgentPaths(projectCwd, agentId).catch(() => [])).find(path => path.endsWith('.jsonl'));
const detail = jsonl ? lastAgentFailureDetail(await readFile(jsonl, 'utf8').catch(() => '')) : undefined;
if (!isTransientAgentFailure(detail))
return;
agentRetries.set(agentId, attempts + 1);
console.log(`[framework] agent ${agentId} died to a transient error (${detail}); continuing it in ${(retryDelayMs ?? TRANSIENT_RETRY_DELAY_MS) / 1000}s, attempt ${attempts + 1} of ${MAX_TRANSIENT_RETRIES}`);
// Unref'd: a pending retry must never hold the daemon open, and a daemon that exits first
// simply does not retry — #923's resume owns the restart case.
const timer = setTimeout(() => {
void onStart(RETRY_PROMPT, 'build', {
...options,
// Unattended like #923's resume: nobody is watching a retry, and the agent must end.
unattended: true,
continueAgentId: agentId,
...(meta.sessionId ? { resumeSession: meta.sessionId } : {}),
}, targetProjectId).then(result => {
if (!result.ok)
console.log(`[framework] could not continue agent ${agentId} after its transient death: ${result.error}`);
});
}, retryDelayMs ?? TRANSIENT_RETRY_DELAY_MS);
timer.unref?.();
};
// Start-from-dashboard (#345): spawn `framework --agent <spec>` for the checkout
// as a detached child — the same spawn ensureDaemon uses for the daemon itself. The agent
// streams into the page via its tailed event log, and its gates + Stop steer through the
// control channel (#344).
//
// Concurrency is per agent, not per project (#736): the one-run-per-project (#393) refusal
// existed because two agents shared one working tree, and worktrees remove that collision.
// Rom's call on the cap is unbounded ("the best solution for the user unless/until we
// stumble upon issues"), so the guard now only refuses a duplicate of the *same* checkout —
// which in practice means the fallback path above.
const onStart = async (prompt, kind, options = {}, targetProjectId) => {
// Ctrl-C closes everything: a Start that lands while the daemon is shutting down is refused,
// never spawned into the gap between the stop pass and the server actually closing.
if (closing)
return { ok: false, error: 'the daemon is shutting down' };
// Run on a connected device (#1067): forward the agent to the remote daemon and relay its events
// back, without allocating a worktree or touching this daemon's busy guard; the remote owns
// both. `remote` is stripped so the remote starts an ordinary local agent and does not relay on.
// Slice 1 runs in the device's own home checkout; which remote project it targets is a later slice.
if (options.remote) {
const { remote, ...forwarded } = options;
const result = await startRemoteAgent(remote, { prompt, kind, options: forwarded });
if (result.ok && result.agentId) {
// A relayed agent has no local worktree or pid, so its list row is a memory-only stub (#1077):
// registered here so onAgents can show it and a dashboard reload re-opens it. Never written to disk.
const now = new Date().toISOString();
const meta = {
status: 'running',
id: result.agentId,
startedAt: now,
updatedAt: now,
target: 'remote',
...(prompt ? { intent: prompt } : {}),
...(remote.label ? { remoteLabel: remote.label } : {}),
};
relayedAgents.register(result.agentId, remote, meta, targetProjectId ?? homeId);
}
return result;
}
const projectKey = targetProjectId ?? homeId;
const projectCwd = await resolveProject(targetProjectId);
if (!projectCwd)
return { ok: false, error: `unknown project: ${targetProjectId}` };
let realBin;
try {
realBin = resolveSpawnBin(binPath);
}
catch (err) {
return { ok: false, error: errorMessage(err) };
}
// A continuation start carries only its seed (#1467): the composer's Resume sends
// `{resumeSession, continueAgentId, agent}` and nothing else, so the agent's armed handoff fell
// back to bare defaults — a session that ran its first leg merge-armed resumed with the merge
// silently disarmed and ended in a draft PR. The project's resolved options are the base and
// the caller's explicit ones stay on top. A fresh start is untouched — the launcher resolves
// its options client-side and sends them whole.
if (options.continueAgentId) {
options = { ...(await resolveProjectAgentOptions(projectKey, env)), ...options };
// A Resume fired the instant its agent flips `done` can also land while the child that wrote
// that ending is still mid-exit (#1529): the slot then still holds a live pid, and the busy
// guard below refused a continuation of a session that is over by its own account. Wait the
// exit and its queued retirement out, so the guard judges only real collisions and the
// checkout reuse reads a settled archive.
const { continueAgentId } = options;
await waitOutFinishedLeg(scopedKey(projectKey, continueAgentId), { starting, activeAgents: activeAgents, retiring }, async () => {
// The composed read (live meta wins over archive): the leg just wrote `done` into its
// worktree and teardown has not archived it yet, so the archive-only list cannot see it.
// No row at all is `unknown`, never `ended` (#1540): the leg is mid-teardown, or its
// meta was caught mid-rewrite, and neither says anything about whether it is still up.
const meta = continueAgentId ? await findAgent(projectCwd, continueAgentId).catch(() => undefined) : undefined;
if (!meta)
return 'unknown';
return meta.status === 'running' ? 'running' : 'ended';
}, FINISHED_LEG_EXIT_GRACE_MS);
}
// An agent must not spend a branch and a worktree on a driver that can never start (#1326).
// That is what #1323 looked like from outside: six projects' worth of agent branches piling up
// while every session died before writing agent.json, with the dashboard stuck on "Waiting for
// the session to start...". Probed here, above the allocation, because this is the one place
// a daemon-started agent is born; the CLI's own path has gated on preflight since #542.
const preflightError = await checkAgentReady(options);
if (preflightError)
return { ok: false, error: preflightError };
// Continuing an existing agent (#762) reuses its id, checkout and log; anything else is new.
const continued = options.continueAgentId ? await continueWorkspace(projectCwd, options.continueAgentId) : undefined;
// A repo that could not be given a worktree fails the Start rather than borrowing the user's
// own checkout (#997); the dashboard shows the reason, and starting again is the retry.
const allocated = continued
? { ok: true, workspace: continued }
: await allocateWorkspace(projectCwd, agentIdFromStartedAt(new Date().toISOString()));
if (!allocated.ok)
return { ok: false, error: allocated.error };
const workspace = allocated.workspace;
// An agent in its own worktree is keyed by that worktree, so it never collides with a
// sibling; a fallback agent is keyed by the project, restoring the one-at-a-time guard.
const key = scopedKey(projectKey, workspace.agentId);
const active = activeAgents.get(key);
if (starting.has(key) || (active !== undefined && isPidAlive(active))) {
return { ok: false, busy: true, error: 'a session is already active for this project; stop it or wait for it to finish' };
}
activeAgents.delete(key);
starting.add(key);
try {
// [Research] (#331) carries an empty prompt fine: its "what" defaults to `this PR`. A
// `prompt` kind (#353) is a preset the user reviewed in the textarea: run it verbatim,
// never re-render. `agentId` is the id its worktree is named with, so the directory and the
// run recorded inside it are one string — and tells it the framework owns its branch.
const specPath = await writeAgentSpec({
prompt,
kind,
cwd: workspace.cwd,
...(workspace.agentId ? { agentId: workspace.agentId } : {}),
// Reopen the agent's log instead of truncating it: the follow-up IS that agent.
...(continued ? { continueAgent: true } : {}),
options,
}, env);
// Re-checked right before the spawn because everything above is awaited (#983): an agent
// spawned past the stop pass is missing from the snapshot stopAgents terminates, so
// nothing would ever stop it. The refusal takes back everything the way here allocated —
// the spec, and the fresh worktree + branch (a continuation's checkout is the agent's own,
// not this refusal's to remove). Left standing, the worktree would have no agent.json, and
// the next boot's sweep would reclaim it by pushing an empty junk branch.
if (closing) {
await removeAgentSpec(specPath, env);
if (!continued && workspace.agentId) {
await removeWorktree(projectCwd, workspace.cwd).catch(() => { });
await pruneWorktrees(projectCwd).catch(() => { });
await nodeGitRunner()(['branch', '-D', agentBranchName(workspace.agentId)], projectCwd).catch(() => { });
}
return { ok: false, error: 'the daemon is shutting down' };
}
const child = spawnDetached(realBin, specPath, ...(workspace.agentId ? [agentStderrPath(workspace.cwd)] : []));
// The agent narrates itself through its own `.the-framework/events.jsonl`, which the
// dashboard streams over `GET /_rpc/events`; the daemon just tracks liveness.
const settle = (detail) => {
activeAgents.delete(key);
const { cwd: checkout, agentId } = workspace;
if (!agentId)
return;
// The failed marker lands before the teardown reads the meta (#1261), so a boot death is
// archived as `failed` and the worktree is then kept for inspection, not removed. After
// teardown the archive is readable, which is when a transient death earns a retry (#1281).
parkRetirement(key, markFailedStart(checkout, agentId, prompt, detail)
.catch(() => { })
.then(() => tearDownWorktree(projectCwd, checkout, agentId))
.then(() => retryTransientDeath(projectCwd, targetProjectId, agentId, options))
.catch(() => { }));
};
child.once('error', err => {
// The child never ran, so nothing consumed the spec: remove it here or the prompt stays on disk.
void removeAgentSpec(specPath, env);
settle(`its process could not be spawned (${errorMessage(err)})`);
});
child.once('exit', (code, signal) => {
// A child that died before reading its spec leaves the prompt (and any device token) on
// disk; one that consumed it makes this a no-op.
void removeAgentSpec(specPath, env);
settle(exitDetail(code, signal));
});
if (child.pid !== undefined)
activeAgents.set(key, child.pid);
// Hand back the agent's id (#761) so the dashboard can select this agent rather than guess.
return { ok: true, ...(workspace.agentId ? { agentId: workspace.agentId } : {}) };
}
finally {
starting.delete(key);
}
};
// Add project(s) (#396): install a single repo, or every git repo directly under a
// directory, then register each so it appears in the Projects list. installProject is
// idempotent (an already-activated repo is a no-op success); a git failure on any target
// aborts and surfaces as an error the dialog shows.
const onAddProject = async (path, directory) => {
// Resolve relative input against the daemon cwd, and check the directory really
// exists first: without this a bad path reaches git as a missing cwd, which
// surfaces as the confusing "spawn git ENOENT" rather than a path error.
const abs = resolve(path);
const isDir = await stat(abs).then(s => s.isDirectory()).catch(() => false);
if (!isDir)
return { ok: false, error: `path does not exist or is not a directory: ${abs}` };
const targets = directory ? await enumerateGitRepos(abs) : [abs];
if (!targets.length)
return { ok: false, error: `no git repositories found under ${abs}` };
let added = 0;
let alreadyActivated = 0;
for (const repo of targets) {
const result = await installProject(repo);
if (!result.ok)
return { ok: false, error: result.error };
if (result.alreadyActivated)
alreadyActivated++;
else
added++;
await addProject(repo, new Date().toISOString()).catch(() => { });
}
return { ok: true, added, alreadyActivated };
};
/**
* How many agents are live on a project (#685). Agent keys are `<projectKey>::<agentId>`, or the
* bare project key for an agent that got no worktree, so both spellings count. The pid is
* re-checked rather than trusted: `settle` clears the entry on exit, but an agent whose exit
* event never arrived would otherwise keep a project looking busy forever.
*/
const activeAgentCount = (targetProjectId) => {
let live = 0;
for (const [key, pid] of activeAgents) {
if (!keyBelongsTo(key, targetProjectId))
continue;
if (isPidAlive(pid))
live++;
}
return live + [...starting].filter(key => keyBelongsTo(key, targetProjectId)).length;
};
/** See {@link ProjectRuntime.busyAgentIds}: every slot the daemon has not finished with. */
const busyAgentIds = () => {
const ids = new Set();
for (const key of [...activeAgents.keys(), ...starting, ...retiring.keys()]) {
const { agentId } = parseScopedKey(key);
if (agentId)
ids.add(agentId);
}
return ids;
};
/**
* Stop the agents this daemon spawned. Ctrl-C closes everything: the dashboard runs in the
* foreground, and nothing it started outlives it.
*
* A spawned agent is detached so it survives the CLI that asked for it, not so it survives the
* daemon that owns it: left alone it becomes an orphan on `ppid 1`, holding a worktree and a
* headless browser, with no daemon left that knows about it. So each gets a SIGTERM, which the
* run already handles by aborting cleanly and group-killing its agent, and a SIGKILL if it will
* not go. Only runs in `activeAgents` — an agent this daemon merely steers is not its to stop.
*
* What is stopped here is not lost, it is just not restarted for you: the agent keeps its branch,
* and its checkout too until the work reaches the remote (E5), so the next start continues the
* same conversation in the same checkout — when you ask for it.
*
* Resolving means the daemon has let go of the repo, not merely that the processes are dead —
* see {@link waitOutSlots}. The archive commit that runs right behind this depends on it.
*/
const stopAgents = async (graceMs = 5000) => {
closing = true;
const stopping = [...activeAgents.entries()];
let stopped = 0;
for (const [, pid] of stopping)
if (await terminate(pid, graceMs))
stopped++;
// Each slot is left for its own `settle` to clear, so the wait can tell a teardown that has
// yet to start from one that has already finished.
await waitOutSlots(stopping.map(([key]) => key), { activeAgents: activeAgents, retiring }, graceMs);
for (const [key] of stopping)
activeAgents.delete(key);
return stopped;
};
// The dashboard's events source (#1067): a stream for an agent this daemon is relaying from a device,
// else undefined so `onEvents` tails the on-disk log as usual for an ordinary local agent.
const remoteEventsSource = (_projectId, agentId) => relayedAgents.get(agentId);
// Tail a relay-started agent's own log (#1067) for the `/_relay/events` endpoint. The relocating
// tail, for the same reason as the dashboard's onEvents: teardown moves the journal into the
// archive, and the device's fixed-path tail went silent without the agent's final events. The
// initial attach takes whatever the resolver answers (a non-git fallback agent's journal IS the
// root one); a relocation refuses the root fallback — there it is another agent's feed.
const rootJournal = join(cwd, FRAMEWORK_DIR, EVENTS_FILE);
const tailRelayEvents = (agentId, onEvent) => {
let initial = true;
return tailAgentEvents(async () => {
const next = await resolveAgentEventsPath(cwd, agentId);
if (initial) {
initial = false;
return next;
}
return next === rootJournal ? undefined : next;
}, onEvent);
};
const dispose = async () => {
relayedAgents.dispose();
};
return {
onStart,
onAddProject,
remoteEventsSource,
tailRelayEvents,
remoteAgents,
onRelayRpc,
activeAgentCount,
busyAgentIds,
stopAgents,
dispose,
};
}
//# sourceMappingURL=daemon-runtime.js.map