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.
896 lines • 42.9 kB
JavaScript
import { join } from 'node:path';
import { hostname } from 'node:os';
import { nodeFs } from '../node-fs.js';
import { agentIdFromWorktreeDir, isWorktreeDirName, DATA_BRANCH } from '../branch-names.js';
/**
* Persisted orchestration state (#211). The dashboard is a pure projection of the
* {@link FrameworkEvent} stream, so persisting *is* durably logging that stream:
* the stack rationale, the loop status, and the decisions ledger are all events
* that already flow through it. We store the log append-only and rehydrate a
* restarted dashboard by replaying it into a fresh stream — no separate state
* model to keep in sync. Per the sync, we do **not** persist the agent's chat
* transcript (Claude Code owns that); only our own orchestration events.
*/
/**
* The directory, under the workspace root, that holds the persisted agent (#313):
* one dir holds both the transient agent state (events.jsonl / agent.json) and the
* committed agent archive (agents/, #1179); a seeded `.the-framework/.gitignore`
* keeps the agent state untracked.
*/
export const FRAMEWORK_DIR = '.the-framework';
/**
* Per-run worktrees live under `<repo>/.the-framework/branches/` (#736/#1580), each in a dir
* named as its branch. Kept out of git by the install-time `.the-framework/.gitignore` (`*` rule,
* #313), so a worktree's checkout never shows up as dirty in the parent. Declared here beside
* {@link FRAMEWORK_DIR} rather than in `worktree.ts`, which imports from this module:
* {@link readLiveMetas} needs it to find the runs living in those worktrees, and the other
* direction would be an import cycle.
*/
export const BRANCHES_DIR = 'branches';
/** The append-only event log: one {@link FrameworkEvent} per line (JSONL). */
export const EVENTS_FILE = 'events.jsonl';
/** A small snapshot for cheap status reads without replaying the whole log. */
export const META_FILE = 'agent.json';
/**
* Where finished agents are archived, under both placements that name has: the lasting
* `agents/<user>/` on the data branch's checkout (#1179/#1582), and the transient
* `.the-framework/agents/` that an agent with no worktree of its own — or one archiving inside
* its own throwaway checkout — writes into. {@link archiveDir}/{@link committedArchiveDir} are
* where a caller picks; both are read when a project's history is listed.
*
* The live agent stays at `events.jsonl`/`agent.json` (the daemon tails it); on
* {@link AgentStore.close} a copy lands here as `<id>.jsonl` + `<id>.json` (#303), giving the
* history sidebar a per-agent log to replay.
*
* The name lives here rather than in `sessions.ts`, which owns the per-user naming: that module
* reads the store, so the constant travelling the other way would be a cycle.
*/
export const ARCHIVE_DIR = 'agents';
/** Filesystem-safe, lexicographically-sortable agent id from an ISO start time. */
export function agentIdFromStartedAt(startedAt) {
// ISO is fixed-width, so replacing the `:`/`.` separators keeps lexical order
// in step with chronological order — the history list sorts by id alone.
return startedAt.replace(/[:.]/g, '-');
}
/** An agent id is path-safe: no separators or traversal, only our own charset. */
export function isSafeAgentId(id) {
return /^[A-Za-z0-9_-]+$/.test(id);
}
/**
* The inverse of {@link agentIdFromStartedAt}, for a caller that has the id but not the meta
* (#1251): the CLI's end-of-run handoff needs the start time to tell the agent's own PR from a
* predecessor's on the same branch name. Undefined for an id that is not one of ours.
*/
export function startedAtFromAgentId(id) {
const match = /^(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/.exec(id);
return match ? `${match[1]}:${match[2]}:${match[3]}.${match[4]}Z` : undefined;
}
/**
* Fold one event into the running {@link AgentMeta}. Pure, so the same derivation
* drives both a live append and reconstructing meta from a replayed log.
*/
export function applyEventToMeta(meta, event, at) {
const next = { ...meta, updatedAt: at };
switch (event.kind) {
case 'session':
next.driver = event.driver;
next.workspace = event.workspace;
if (event.sessionLink)
next.sessionLink = event.sessionLink;
// Per-leg (#1438): a leg that recorded no model leaves it unknown rather than
// inheriting the prior leg's — the agent may have resolved a different default.
if (event.model)
next.model = event.model;
else
delete next.model;
break;
case 'session-update':
next.sessionId = event.sessionId;
if (event.sessionLink)
next.sessionLink = event.sessionLink;
break;
case 'session-name':
next.sessionName = event.name;
break;
case 'ready-for-merge':
next.readyForMerge = true;
break;
case 'choice':
next.pendingChoice = { id: event.id, title: event.title };
break;
case 'choice-resolved':
if (next.pendingChoice?.id === event.id)
delete next.pendingChoice;
break;
case 'intent':
next.intent = event.text;
break;
case 'browser-stream':
next.browserStreamPort = event.port;
break;
case 'handoff-armed':
next.handoff = { push: event.push, pr: event.pr, ...(event.merge !== undefined ? { merge: event.merge } : {}) };
break;
case 'handoff':
next.handoffReport = event.outcome;
// Cleared on a non-skipped outcome rather than only ever set: a resumed run's second leg
// can publish after its first skipped, and a stale `no-commits` on a published run is
// exactly the lie the release must not act on.
if (event.outcome === 'skipped')
next.handoffSkip = event.reason;
else
delete next.handoffSkip;
if (event.outcome !== 'failed' && event.merge)
next.mergeOutcome = event.merge.outcome;
break;
case 'ticket':
next.ticket = event.path;
break;
case 'pull-request':
next.pr = { number: event.number, url: event.url };
break;
case 'branch':
next.branch = event.branch;
break;
case 'cloud-anchor':
next.cloudAnchor = event.sha;
break;
case 'settled':
next.settledAt = at;
break;
case 'driver':
// Any new turn means the agent is working again, so the agent is no longer parked (#785).
if (event.event.type === 'start')
delete next.settledAt;
break;
case 'end':
next.status = event.ok ? 'done' : event.stopped ? 'stopped' : 'failed';
delete next.pendingChoice; // a finished run is not awaiting anything
delete next.settledAt; // nor is it waiting on you
// The bridge dies with the agent, so a kept port would send the pane at whatever else
// the OS handed that number next.
delete next.browserStreamPort;
break;
default:
break;
}
return next;
}
/** The seed meta an agent starts from, before any event is folded in. */
function freshMeta(startedAt, intent, owner, id, target, kind) {
return {
status: 'running',
id: id && isSafeAgentId(id) ? id : agentIdFromStartedAt(startedAt),
startedAt,
updatedAt: startedAt,
...(owner ? { pid: owner.pid, host: owner.host } : {}),
...(intent ? { intent } : {}),
// Only a non-local target travels; `local` is the default every reader already assumes.
...(target && target !== 'local' ? { target } : {}),
...(kind ? { kind } : {}),
};
}
/**
* Parse a JSONL event log. A blank or malformed trailing line (e.g. a crash
* mid-write) stops the read rather than throwing, so a partial agent still replays
* everything up to the cut.
*/
function parseEventLog(raw) {
const events = [];
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed)
continue;
try {
events.push(JSON.parse(trimmed));
}
catch {
break; // a torn last line from an interrupted write; keep what we have
}
}
return events;
}
/** How many times a meta that would not parse is re-read before it is called corrupt (#1540). */
const TORN_META_READ_RETRIES = 2;
/** The pause between those re-reads: long enough for the write that tore the read to land. */
const TORN_META_READ_DELAY_MS = 5;
/**
* Read + parse a persisted {@link AgentMeta} file, or `undefined` if missing/unreadable.
*
* Re-read on a parse failure (#1540). {@link writeMetaFile} closes this off at the source, so this
* is the backstop rather than the defence: a meta written through an adapter with no `rename` is
* still written in place, so a reader can catch it mid-truncate, and reporting that as `undefined`
* makes a live agent *vanish* from every composed read for one poll. A torn read is transient by
* construction, so ask again; a file still unparseable after the retries is genuinely corrupt and
* yields `undefined`, exactly as before.
*/
async function readMetaFile(fs, path) {
for (let attempt = 0;; attempt++) {
if (!(await fs.exists(path)))
return undefined;
try {
return JSON.parse(await fs.read(path));
}
catch {
if (attempt >= TORN_META_READ_RETRIES)
return undefined;
await new Promise(resolve => setTimeout(resolve, TORN_META_READ_DELAY_MS));
}
}
}
/**
* Write a {@link AgentMeta} file. The one owner of the on-disk encoding, symmetric to
* {@link readMetaFile} — every meta write in this module goes through it.
*
* Written beside the target and renamed over it (#1540). A meta is rewritten by the agent that owns
* it, over and over, while the daemon and every dashboard read poll it from another process; a
* plain write truncates the file before it refills, so a reader landing in that window saw an
* empty one and reported the agent *gone*. A rename swaps the whole file in one step, so a reader
* gets either the entire previous meta or the entire new one and never a half of either — which
* {@link readMetaFile}'s retry can only paper over, never prevent. An adapter with no `rename`
* writes in place as before.
*/
async function writeMetaFile(fs, path, meta) {
const contents = JSON.stringify(meta, null, 2) + '\n';
if (!fs.rename)
return fs.write(path, contents);
// Named for the writing process: an agent and the daemon teardown archiving it can both be
// writing the same meta, and they must not share a scratch file and splice their writes into
// one. `.tmp` also keeps it out of the archive listing, which takes only `.json`.
const scratch = `${path}.${process.pid}.tmp`;
await fs.write(scratch, contents);
await fs.rename(scratch, path);
}
/**
* The `end` event written on behalf of an agent whose process died without reporting one (#1359):
* a crash, a `kill -9`, or the empty-event-loop exit a parked gate used to cause. Every reader
* of the stream — the dashboard's outcome pill, its choice rail, the meta fold — keys "over"
* off a single `end` event, so a death that skipped it left the agent's last question rendering
* as answerable forever while its picks were read by nobody.
*/
function orphanEndEvent() {
return { kind: 'end', ok: false, stopped: true, detail: 'its process died without reporting an end' };
}
/**
* Record a dead agent's missing ending in place (#1359): append the surrogate `end` event to the
* checkout's live log and fold it into the meta via {@link applyEventToMeta} — so the status
* flips to `stopped` and a `pendingChoice` the agent died holding expires exactly as a run-written
* end would expire it. Best-effort on both writes: healing must never make a read throw.
*/
async function recordOrphanEnd(fs, dir, meta) {
const event = orphanEndEvent();
await fs.append(join(dir, EVENTS_FILE), JSON.stringify(event) + '\n').catch(() => { });
const ended = applyEventToMeta(meta, event, new Date().toISOString());
await writeMetaFile(fs, join(dir, META_FILE), ended).catch(() => { });
return ended;
}
/**
* Flip the live agent at `dir` to `stopped` and archive it, returning the stopped meta. The
* shared tail of every self-heal: a `running` meta whose process is gone must both stop
* showing as live and keep its history. The flip goes through {@link recordOrphanEnd}, so
* the log gains the `end` event the dead process never wrote (#1359) before the archive
* copies it. Best-effort on both writes — healing must never make a read throw.
*/
async function stopAndArchiveLive(fs, dir, meta) {
const stopped = await recordOrphanEnd(fs, dir, meta);
await archivePriorAgent(fs, dir).catch(() => { });
return stopped;
}
/** Rebuild {@link AgentMeta} from a full event log (used when resuming). */
export function metaFromEvents(events, startedAt) {
let meta = freshMeta(startedAt);
for (const event of events)
meta = applyEventToMeta(meta, event, startedAt);
return meta;
}
/**
* Durable, append-only store for a single agent's orchestration events, plus a
* derived {@link AgentMeta} snapshot. Writes are serialized through one tail
* promise so an append and its meta rewrite never interleave; {@link close}
* flushes that queue before the process exits.
*/
export class AgentStore {
fs;
dir;
clock;
tail = Promise.resolve();
meta;
/**
* The intent a continuation must keep (#762/#1467): a reopened session keeps its original
* label, but a continuation's own `intent` event carries the resume message and would relabel
* the row through {@link applyEventToMeta}'s normal refinement. Unset for a fresh session,
* where that refinement stands.
*/
pinnedIntent;
constructor(fs, dir, clock, startMeta) {
this.fs = fs;
this.dir = dir;
this.clock = clock;
this.meta = startMeta;
}
/** The event log path. */
get eventsPath() {
return join(this.dir, EVENTS_FILE);
}
/** The meta snapshot path. */
get metaPath() {
return join(this.dir, META_FILE);
}
/**
* Open (creating `.the-framework/` if needed) under the workspace `cwd`. `fresh`
* truncates any prior log for a new agent; the default preserves it so a resume
* can {@link loadEvents}.
*/
static async open(cwd, opts = {}) {
const fs = opts.fs ?? nodeStoreFs();
const dir = join(cwd, FRAMEWORK_DIR);
const now = opts.now ?? new Date().toISOString();
await fs.mkdir(dir);
const owner = opts.owner ?? { pid: process.pid, host: hostname() };
const clock = opts.clock ?? (() => new Date().toISOString());
const store = new AgentStore(fs, dir, clock, freshMeta(now, opts.intent, owner, opts.id, opts.target, opts.kind));
if (opts.continueAgent) {
// Reopen: the log stays, the row keeps its original intent, and this process takes ownership
// so a liveness probe (#716) reads the agent as alive rather than as an orphan.
const prior = await readMetaFile(fs, store.metaPath);
if (prior) {
store.meta = { ...prior, status: 'running', pid: owner.pid, host: owner.host, updatedAt: now };
store.pinnedIntent = prior.intent;
await store.writeMeta();
return store;
}
}
if (opts.fresh) {
// A new agent truncates the live log. First rescue the prior agent if it never
// got archived (e.g. a crash exited before close), so no history is lost.
await archivePriorAgent(fs, dir).catch(() => { });
await fs.write(store.eventsPath, '');
await store.writeMeta();
}
return store;
}
/**
* Append one event to the log and refresh the meta snapshot. Fire-and-forget at
* the call site: internally chained so writes stay ordered. A failed write is
* swallowed (persistence is best-effort — it must never break a live agent).
*/
append(event) {
this.meta = applyEventToMeta(this.meta, event, this.clock());
// A continuation keeps the session's original label (#762) even though its own `intent`
// event carries the resume message rather than a name (#1467).
if (this.pinnedIntent)
this.meta = { ...this.meta, intent: this.pinnedIntent };
this.tail = this.tail
.then(() => this.fs.append(this.eventsPath, JSON.stringify(event) + '\n'))
.then(() => this.writeMeta())
.catch(err => {
console.error('[framework] failed to persist orchestration state:', err);
});
return this.tail;
}
/**
* Flush any queued writes, then archive this agent into `agents/` so it shows up in
* the dashboard's history (#303). Both best-effort: persistence must never break
* an agent, so an archive failure is logged, not thrown.
*/
async close() {
await this.tail;
try {
await archiveAgent(this.fs, archiveDir(this.dir), this.meta, this.eventsPath);
}
catch (err) {
console.error('[framework] failed to archive run history:', err);
}
}
/** The current derived snapshot. */
snapshot() {
return { ...this.meta };
}
/**
* Read and parse the persisted event log. A blank or malformed trailing line
* (e.g. a crash mid-write) is skipped rather than throwing, so a partial agent
* still replays everything up to the cut. Missing file yields `[]`.
*/
async loadEvents() {
if (!(await this.fs.exists(this.eventsPath)))
return [];
return parseEventLog(await this.fs.read(this.eventsPath));
}
/** Read the persisted meta snapshot, or `undefined` if none/unreadable. */
readMeta() {
return readMetaFile(this.fs, this.metaPath);
}
writeMeta() {
return writeMetaFile(this.fs, this.metaPath, this.meta);
}
}
/**
* The transient archive inside a `.the-framework` dir: what an agent's own worktree copy and the
* crash rescue use. Untracked by design — the lasting copy lives on the data branch (#1582).
*/
function archiveDir(dir) {
return join(dir, ARCHIVE_DIR);
}
/**
* A user's lasting archive (#1179/#1582): `agents/<user>/` at the root of the data branch's
* checkout. On the branch, so the history survives `git clean -fdx` AND never touches the code
* history; per user, so two people's machines write side by side instead of conflicting.
*/
function committedArchiveDir(cwd, user) {
return join(cwd, FRAMEWORK_DIR, BRANCHES_DIR, DATA_BRANCH, ARCHIVE_DIR, user);
}
/** Paths of an agent's archived log + meta inside one archive directory. */
function archivePaths(agentsDir, id) {
return { events: join(agentsDir, `${id}.jsonl`), meta: join(agentsDir, `${id}.json`) };
}
/**
* Where one agent's archive actually sits, searched across {@link archiveDirs}, or `undefined` when
* it is nowhere. An agent id alone no longer names a path: which user archived it decides that, and a
* reader (the continue (#762), a removal) only has the id.
*/
async function findArchive(fs, cwd, agentId) {
for (const agentsDir of await archiveDirs(fs, cwd)) {
const paths = archivePaths(agentsDir, agentId);
if (await fs.exists(paths.meta))
return paths;
}
return undefined;
}
/**
* Every directory a project's archived agents may sit in, committed first: each user's
* `agents/<user>/` on the data branch's checkout (#1582), then the transient
* `.the-framework/agents/` an agent with no worktree archives into.
*
* Every user's archive is listed, not just the reader's — the history is a team-visible record of
* what the agent has done to the repo, which is the point of committing it.
*
* A directory is recognized by having a readable archive child, so a stray file is simply not one
* (readdir yields `[]` for anything that is not a directory).
*/
async function archiveDirs(fs, cwd) {
const dirs = [];
const committed = join(cwd, FRAMEWORK_DIR, BRANCHES_DIR, DATA_BRANCH, ARCHIVE_DIR);
for (const name of await fs.readdir(committed)) {
const candidate = join(committed, name);
if ((await fs.readdir(candidate)).length > 0)
dirs.push(candidate);
}
dirs.push(join(cwd, FRAMEWORK_DIR, ARCHIVE_DIR));
return dirs;
}
/**
* Copy an agent's live log + meta into `agentsDir` as `<id>.jsonl` / `<id>.json`. The live files
* stay put (the daemon keeps tailing them until the next agent); this is a durable snapshot for
* the history list. Idempotent per id.
*/
async function archiveAgent(fs, agentsDir, meta, eventsPath) {
if (!isSafeAgentId(meta.id))
return;
await fs.mkdir(agentsDir);
const out = archivePaths(agentsDir, meta.id);
const events = (await fs.exists(eventsPath)) ? await fs.read(eventsPath) : '';
await fs.write(out.events, events);
await writeMetaFile(fs, out.meta, meta);
}
/**
* Archive the agent currently sitting in the live files, unless it is already in
* `agents/`. Used at the start of a fresh agent so a crash that skipped
* {@link AgentStore.close} still leaves its history behind.
*/
async function archivePriorAgent(fs, dir) {
const meta = await readMetaFile(fs, join(dir, META_FILE));
if (!meta?.id || !isSafeAgentId(meta.id))
return;
if (await fs.exists(archivePaths(archiveDir(dir), meta.id).meta))
return;
await archiveAgent(fs, archiveDir(dir), meta, join(dir, EVENTS_FILE));
}
/**
* Put an archived agent's history back where an agent reads it (#762), so a continued agent picks up its
* own log rather than starting empty. The inverse of {@link archiveWorktreeAgent}: teardown moved the
* history to the repo, and continuing needs it in the checkout again.
*
* A no-op when the worktree already holds a live agent (nothing to restore, and its log is newer),
* or when there is no archive. Never throws.
*/
export async function restoreArchivedAgent(repo, worktree, agentId, fs = nodeStoreFs()) {
try {
if (!isSafeAgentId(agentId))
return false;
const dir = join(worktree, FRAMEWORK_DIR);
if (await fs.exists(join(dir, META_FILE)))
return false;
const archive = await findArchive(fs, repo, agentId);
if (!archive)
return false;
await fs.mkdir(dir);
await fs.write(join(dir, EVENTS_FILE), (await fs.exists(archive.events)) ? await fs.read(archive.events) : '');
await fs.write(join(dir, META_FILE), await fs.read(archive.meta));
return true;
}
catch {
return false;
}
}
/**
* Every checkout directory on disk (#1580). Only the run branch spelling counts — the same
* directory holds the rename links (#1589), which are views, not checkouts. Forgiving: a missing
* root yields nothing.
*/
export async function worktreeDirEntries(cwd, fs = nodeStoreFs()) {
const root = join(cwd, FRAMEWORK_DIR, BRANCHES_DIR);
const entries = [];
for (const name of await fs.readdir(root).catch(() => [])) {
const agentId = agentIdFromWorktreeDir(name);
if (isWorktreeDirName(name) && isSafeAgentId(agentId))
entries.push({ path: join(root, name), agentId });
}
return entries;
}
/**
* The agent ids that have a worktree directory (#737/#1580). Forgiving — a project that never ran
* concurrently has no such dir and yields `[]`.
*/
export async function listWorktreeDirs(cwd, fs = nodeStoreFs()) {
return [...new Set((await worktreeDirEntries(cwd, fs)).map(entry => entry.agentId))];
}
/**
* Archive a worktree agent's history into the *main repo* (#737), returning the meta it archived.
*
* An agent writes its `agent.json` / `events.jsonl` inside its own worktree (#736), so deleting that
* worktree would delete the agent's history with it. This copies it into the repo, which is the one
* place the dashboard's history reads from, so teardown becomes safe.
*
* `user` files the copy under that user's lasting `agents/<user>/` on the data branch's checkout
* (#1179/#1582) instead of the transient `agents/`. It is this copy, not the one the agent left in
* its own worktree, that is meant to last: every agent in a git repo gets a worktree, so this is
* the only archive of it that outlives the checkout. The caller owns getting it committed — the
* daemon funnels this through the data branch's writer. The worktree's own copy deliberately
* stays untracked — it would otherwise be committed onto the agent's branch as well and collide
* with this one on merge.
*
* A meta still marked `running` is flipped to `stopped` first: this runs when the process is
* already gone, so `running` means it died without closing (crash, kill -9), exactly the case
* {@link reconcileOrphanedAgents} handles for the project path. Idempotent per id, and forgiving:
* a worktree with no run, or an unreadable one, yields `undefined` rather than throwing.
*/
export async function archiveWorktreeAgent(worktree, repo, fs = nodeStoreFs(), branch, user) {
try {
const worktreeDir = join(worktree, FRAMEWORK_DIR);
const live = await readMetaFile(fs, join(worktreeDir, META_FILE));
if (!live?.id || !isSafeAgentId(live.id))
return undefined;
// The flip writes the worktree's own log + meta too (#1359): the death gains its `end`
// event before the archive copies the log, so no reader — live tail or archived replay —
// is left holding an open gate for a dead agent.
const stopped = live.status === 'running' ? await recordOrphanEnd(fs, worktreeDir, live) : live;
// The branch is read from the checkout by the caller and stamped here, because this is the
// last moment it can be observed: the worktree is about to go (#799).
const meta = branch ? { ...stopped, branch } : stopped;
const dest = user ? committedArchiveDir(repo, user) : archiveDir(join(repo, FRAMEWORK_DIR));
await archiveAgent(fs, dest, meta, join(worktreeDir, EVENTS_FILE));
return meta;
}
catch {
return undefined;
}
}
/**
* The archived log + meta paths of one agent, wherever it is filed, or `[]` when it is nowhere.
* Exported so a caller that deletes a session (the dashboard's Remove) does not have to know which
* user archived it — before #1179 the path was derivable from the id alone, and now it is not.
*/
export async function archivedAgentPaths(cwd, agentId, fs = nodeStoreFs()) {
if (!isSafeAgentId(agentId))
return [];
const archive = await findArchive(fs, cwd, agentId).catch(() => undefined);
return archive ? [archive.meta, archive.events] : [];
}
/** Newest run first: an id sorts chronologically, so the id order IS the time order (no parse). */
const byIdDesc = (a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0);
/**
* Whether an `<id>.json` archive entry is older than `since` going by its *name* — an id is the
* run's start time, so the filename dates the record and most of a long history can be rejected
* before it is ever read (#1607).
*
* Only a name that parses as one of our ids can reject: an id handed in from outside is not a
* date, and is read like any other. The id is allocated when the run is spawned and `startedAt`
* written when it first opens its store, so the name can be the older of the two by the length of
* a spawn — a caller filtering on `startedAt` sees a record drop out at most that much early.
*/
function namedBefore(name, since) {
const startedAt = startedAtFromAgentId(name.slice(0, -'.json'.length));
return startedAt !== undefined && Date.parse(startedAt) < since;
}
/**
* Every `agents/*.json` archived meta with the path it was read from, torn/half-written entries
* skipped. The one home of the archived-history read loop, shared by {@link listAgents} and the
* boot reconcile. A missing/unreadable dir throws to the caller, as both callers always let it.
*
* `since` (epoch ms) drops the runs that started before it without reading them; see
* {@link namedBefore}.
*/
async function readArchivedMetaEntries(fs, agentsDir, since) {
const entries = [];
for (const name of await fs.readdir(agentsDir)) {
if (!name.endsWith('.json'))
continue;
if (since !== undefined && namedBefore(name, since))
continue;
const path = join(agentsDir, name);
try {
entries.push({ path, meta: JSON.parse(await fs.read(path)) });
}
catch {
// torn/half-written meta — skip it
}
}
return entries;
}
/**
* A `running` meta whose owning process is provably gone (or unknowable on boot): the orphan
* {@link reconcileOrphanedAgents} flips to `stopped`. A live pid on this host, or a non-running
* meta, is left be. The narrowing lets a caller use the meta as present in the true branch.
*/
function isDeadRunningAgent(meta, isAlive) {
return meta?.status === 'running' && ownerLiveness(meta, isAlive) !== 'live';
}
/**
* Every archived meta a project has, across all of {@link archiveDirs}, with the path it came from.
* De-duplicated by agent id, first directory winning: the crash rescue archives into the transient
* `agents/` and the close into the user's committed one, so an agent can sit in both places and the
* history must show it once. The user directories are searched first, so the committed copy wins.
*/
async function readAllArchivedMetaEntries(fs, cwd, since) {
const seen = new Set();
const entries = [];
for (const agentsDir of await archiveDirs(fs, cwd)) {
for (const entry of await readArchivedMetaEntries(fs, agentsDir, since).catch(() => [])) {
if (seen.has(entry.meta.id))
continue;
seen.add(entry.meta.id);
entries.push(entry);
}
}
return entries;
}
/**
* List a project's archived agents, most-recent first: every user's committed archive plus the
* transient `agents/`. The id sorts chronologically so no timestamp parse is needed. Missing or
* unreadable dir/entries are skipped, never thrown.
*
* `since` (epoch ms) is for a caller that only wants recent runs — a poll on a cadence, not the
* history list. It is answered from the filenames, so the records it excludes cost no read at all.
*/
export async function listAgents(cwd, fs = nodeStoreFs(), since) {
const entries = await readAllArchivedMetaEntries(fs, cwd, since);
return entries.map(entry => entry.meta).sort(byIdDesc);
}
/**
* Whether a `running` meta's owning process is provably there, provably gone, or unknowable.
*
* `'unknown'` is the load-bearing third state (#716/#926): a meta with no `pid` (it predates the
* field) or one owned by another host cannot be probed from here. The two callers treat it
* differently on purpose — the boot reconcile flips an unknown to `stopped` (a fresh daemon
* drives no in-flight run, and there is nothing better to go on), while the self-heal on read
* leaves it alone (a routine read must not kill an agent another machine may own).
*/
function ownerLiveness(meta, isAlive) {
if (meta.status !== 'running' || meta.pid === undefined || meta.host !== hostname())
return 'unknown';
return isAlive(meta.pid) ? 'live' : 'dead';
}
/**
* Reconcile runs a dead process left marked `running` — the live `agent.json`, an archived
* `agents/*.json`, or an agent inside a worktree. Such an agent shows as active while nothing is left
* to read its `control.jsonl`, so its Stop is a no-op. Each is flipped to `stopped`; the live
* run is archived first (idempotent) so its history is kept. Returns how many were reconciled.
* Best-effort: a read/write error skips that agent, never throws.
*
* An agent whose pid is alive on this host is left alone (#926). This used to flip every `running`
* meta on the assumption that a fresh dashboard drives no in-flight run, which holds only while
* exactly one is ever booted: a second one marked genuinely live agents as finished, giving them a
* no-op Stop in the dashboard. A meta with no `pid` keeps the old behaviour, since there is
* nothing better to go on.
*/
export async function reconcileOrphanedAgents(cwd, fs = nodeStoreFs(), isAlive = isPidAlive) {
const dir = join(cwd, FRAMEWORK_DIR);
let fixed = 0;
// Archived agents stuck at `running` (e.g. a prior live agent the next agent never rescued), wherever
// they are archived. Done before the live agent so its fresh archive isn't re-counted here.
for (const { path, meta } of await readAllArchivedMetaEntries(fs, cwd)) {
if (!isDeadRunningAgent(meta, isAlive))
continue;
try {
// The archived pair sits side by side (`<id>.json` + `<id>.jsonl`), so the surrogate end
// (#1359) lands in both: the replayed log sees the agent finish, and the meta fold drops
// the pendingChoice the agent died holding.
const event = orphanEndEvent();
await fs.append(path.replace(/\.json$/, '.jsonl'), JSON.stringify(event) + '\n').catch(() => { });
await writeMetaFile(fs, path, applyEventToMeta(meta, event, new Date().toISOString()));
fixed++;
}
catch {
// write failed — best-effort, skip
}
}
// The live agent: flip it, then archive so a crash that skipped close() still
// leaves the stopped agent in the history list.
const live = await readMetaFile(fs, join(dir, META_FILE));
if (isDeadRunningAgent(live, isAlive)) {
await stopAndArchiveLive(fs, dir, live);
fixed++;
}
// Runs living in worktrees (#736/#737). A daemon that died mid-run never ran its teardown, so
// each of those agents is orphaned the same way — except its history sits inside the worktree,
// where nothing reads it. Flip it in place (so the dashboard stops showing it as live) and copy
// it into the repo's history. The worktree itself is left on disk: an agent that ended this way did
// not end cleanly, and those are kept for inspection. Removing one is an explicit action.
for (const entry of await worktreeDirEntries(cwd, fs)) {
const worktreeDir = join(entry.path, FRAMEWORK_DIR);
const meta = await readMetaFile(fs, join(worktreeDir, META_FILE));
if (!isDeadRunningAgent(meta, isAlive))
continue;
// recordOrphanEnd rather than a bare status flip (#1359): the worktree's log gains the
// `end` event first, so the archive below copies a stream that actually ends.
await recordOrphanEnd(fs, worktreeDir, meta);
await archiveWorktreeAgent(entry.path, cwd, fs).catch(() => undefined);
fixed++;
}
return fixed;
}
/**
* Whether `pid` is a live process on this host. `process.kill(pid, 0)` sends no signal but
* throws `ESRCH` once the process is gone; `EPERM` means it exists under another user (still
* alive). A pid on a *different* host is unknowable here, so callers guard on {@link AgentMeta.host}
* before trusting a result. A recycled pid (another process reusing a dead agent's number) reads as
* alive — an accepted, vanishingly rare miss on a single dev box.
*/
export function isPidAlive(pid) {
try {
process.kill(pid, 0);
return true;
}
catch (err) {
return err.code === 'EPERM';
}
}
/**
* The live (in-progress) run's meta snapshot from `.the-framework/agent.json`, or
* `undefined` when none/unreadable. Unlike {@link listAgents} (which reads the
* archived `agents/` copies written on close), this is the agent the daemon is
* tailing right now — so the dashboard can list it with a `running` status
* before it finishes. Missing or torn file yields `undefined`, never throws.
*
* Self-heals a stale agent on read (#716): if the meta says `running` but its owning process died
* without writing `end` (a crash, `kill -9`, or the machine sleeping), nothing is left to consume
* `control.jsonl` — so Stop is a no-op and the row is stuck. When the owning pid is gone on this
* host, flip it to `stopped` and archive it, so the dashboard clears the row on the next poll
* instead of only after a daemon restart's boot-time {@link reconcileOrphanedAgents}. An agent whose
* meta predates this field (no `pid`) is left untouched — the boot reconcile still catches it.
*/
export async function readLiveMeta(cwd, fs = nodeStoreFs(), isAlive = isPidAlive) {
const dir = join(cwd, FRAMEWORK_DIR);
const meta = await readMetaFile(fs, join(dir, META_FILE));
if (!meta)
return undefined;
// Only a provably dead owner heals here — 'unknown' (no pid / another host) is left alone.
if (ownerLiveness(meta, isAlive) === 'dead')
return stopAndArchiveLive(fs, dir, meta);
return meta;
}
/**
* Every live agent of a project (#738): the list variant of {@link readLiveMeta}.
*
* An agent started from the dashboard gets its own worktree (#736) and writes its `agent.json`
* inside it, so the project path alone no longer sees any of them. This looks in both places:
* each `.the-framework/branches/*` checkout, and the repo root itself, which is where a
* project that cannot be given a worktree (not a git repo) still runs and where every agent
* from before #736 lives.
*
* Each candidate goes through {@link readLiveMeta}, so a stale agent self-heals exactly as it
* did. Newest first, by id. Never throws: an unreadable worktree is skipped.
*/
export async function readLiveMetas(cwd, fs = nodeStoreFs(), isAlive = isPidAlive) {
// The checkouts under `branches/`: run-branch-named dirs, never the rename links beside them.
const candidates = [cwd, ...(await worktreeDirEntries(cwd, fs)).map(entry => entry.path)];
const agents = [];
for (const candidate of candidates) {
const meta = await readLiveMeta(candidate, fs, isAlive).catch(() => undefined);
if (meta)
agents.push({ ...meta, cwd: candidate });
}
return agents.sort(byIdDesc);
}
/**
* Read one archived agent's event log for replay. Returns `undefined` for an
* unknown or unsafe id; a torn trailing line is dropped (same rule as the live
* {@link AgentStore.loadEvents}).
*/
export async function loadAgentEvents(cwd, id, fs = nodeStoreFs()) {
if (!isSafeAgentId(id))
return undefined;
const archive = await findArchive(fs, cwd, id);
if (!archive || !(await fs.exists(archive.events)))
return undefined;
return parseEventLog(await fs.read(archive.events));
}
/** A {@link StoreFs} backed by `node:fs/promises`. See {@link nodeFs}. */
export function nodeStoreFs() {
// Destructured rather than returned whole: the narrow interface is the contract,
// so the object should not carry methods the store was never handed.
const { read, write, append, exists, mkdir, readdir, rename } = nodeFs();
return { read, write, append, exists, mkdir, readdir, rename };
}
/**
* A project's runs: the live ones prepended to the archived history, newest-first. Forgiving —
* a side that cannot be read simply contributes nothing.
*
* Live wins over archived (#768). The dedup used to drop the live copy, which was right while
* "archived" meant "finished for good": an agent was only ever copied into `agents/` on its way out.
* Continuing an agent (#762) breaks that — the agent has an archived copy from its first leg AND is
* live again — and keeping the archive showed a running agent as finished.
*
* This composition, not its two halves, is what every caller actually wants; the store exporting
* only the halves is why three separate modules each grew their own copy of it.
*/
export async function readAllAgents(cwd, fs = nodeStoreFs()) {
const [archived, live] = await Promise.all([
listAgents(cwd, fs).catch(() => []),
readLiveMetas(cwd, fs).catch(() => []),
]);
return [...live, ...archived.filter(agent => !live.some(l => l.id === agent.id))];
}
/**
* One agent's meta by id, live copy winning over archived — {@link readAllAgents}'s rule for a
* single row. The find-by-id shape the RPCs kept privately rebuilding, for the same reason
* the list shape did: the store exported only the halves.
*/
export async function findAgent(cwd, agentId, fs = nodeStoreFs()) {
return (await readAllAgents(cwd, fs)).find(agent => agent.id === agentId);
}
/**
* Read a checkout's live event log (`.the-framework/events.jsonl`). Missing or unreadable
* yields `[]`, and a torn trailing line is dropped — the same rule as
* {@link AgentStore.loadEvents}, exported so a reader outside the store (the Discord bot's gate
* lookup) cannot keep a second parser with a drifted torn-line policy.
*/
export async function readEventLog(cwd, fs = nodeStoreFs()) {
const path = join(cwd, FRAMEWORK_DIR, EVENTS_FILE);
try {
if (!(await fs.exists(path)))
return [];
return parseEventLog(await fs.read(path));
}
catch {
return [];
}
}
/**
* Patch an archived run's record with a fact discovered once the agent's process is gone (E6,
* #1601): the pull request opened for its work, or the branch a cloud session's work landed
* on. There is no event stream left to carry it, and every surface reads the record, so this
* one write is what turns a "nothing committed" row into its real branch and PR.
*
* A plain file write: the archive lives on the data branch's checkout, and a fact written there
* is only durable once committed — {@link patchArchivedAgentOnDataBranch} is the funneled form
* every caller outside a test uses.
*/
export async function patchArchivedAgent(cwd, agentId, patch, fs = nodeStoreFs()) {
if (!isSafeAgentId(agentId))
return false;
try {
const archive = await findArchive(fs, cwd, agentId);
if (!archive)
return false;
const meta = await readMetaFile(fs, archive.meta);
if (!meta)
return false;
await fs.write(archive.meta, JSON.stringify({ ...meta, ...patch }));
return true;
}
catch {
return false;
}
}
//# sourceMappingURL=agent-store.js.map