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.
501 lines • 28.1 kB
TypeScript
import type { FrameworkEvent } from '../events.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 run. Same
* `.the-framework/` directory as the committed project log (#313): one dir holds
* both the transient run state (events.jsonl / run.json / runs/) and the DB
* (LOGS.md); a seeded `.the-framework/.gitignore` keeps the run state untracked.
*/
export declare const FRAMEWORK_DIR = ".the-framework";
/**
* Per-run worktrees live under `<repo>/.the-framework/worktrees/` (#736). 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 declare const WORKTREES_DIR = "worktrees";
/** The append-only event log: one {@link FrameworkEvent} per line (JSONL). */
export declare const EVENTS_FILE = "events.jsonl";
/** A small snapshot for cheap status reads without replaying the whole log. */
export declare const META_FILE = "run.json";
/**
* Where finished runs are archived, so the dashboard can list a project's run
* history (#303). The live run stays at `events.jsonl`/`run.json` (the daemon
* tails it); on {@link RunStore.close} a copy lands here as `<id>.jsonl` +
* `<id>.json`, giving the history sidebar a per-run log to replay.
*/
export declare const RUNS_DIR = "runs";
/**
* Where a project's finished runs are archived now (#1179): `.the-framework/<user>/sessions/`,
* which the install-time ignore un-ignores so the history is committed and survives a
* `git clean -fdx`. {@link RUNS_DIR} stays the transient location — a run worktree still archives
* into its own throwaway checkout there, and it is where every run archived before this shipped
* still lives, so both are read.
*
* The name lives here beside its sibling 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 declare const SESSIONS_DIR = "sessions";
/** Filesystem-safe, lexicographically-sortable run id from an ISO start time. */
export declare function runIdFromStartedAt(startedAt: string): string;
/** A run id is path-safe: no separators or traversal, only our own charset. */
export declare function isSafeRunId(id: string): boolean;
/**
* The inverse of {@link runIdFromStartedAt}, 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 run's own PR from a
* predecessor's on the same branch name. Undefined for an id that is not one of ours.
*/
export declare function startedAtFromRunId(id: string): string | undefined;
/** Bumped when the on-disk shape changes, so a reader can detect an old file. */
export declare const RUN_META_VERSION = 2;
/** How a run ended (or that it is still going). */
export type RunStatus = 'running' | 'done' | 'stopped' | 'failed';
/**
* A queryable snapshot of the run, derived entirely from the event log. Lets the
* dashboard render a header (and a future run list) without parsing every line.
*/
export interface RunMeta {
version: number;
status: RunStatus;
/** Stable, path-safe id for this run (derived from {@link startedAt}). */
id: string;
/** ISO timestamp the store was opened (run start). */
startedAt: string;
/** ISO timestamp of the last event written. */
updatedAt: string;
/** Full-fledged loop passes performed so far. */
passes: number;
/**
* The OS pid of the process that owns this run (the one tailing `control.jsonl`), on {@link host}.
* Persisted so a reader can tell a live run from one whose process died without writing `end`
* (#716): a `running` meta whose owning pid is gone is stale and gets flipped to `stopped`.
*/
pid?: number;
/** The host the owning {@link pid} lives on, so a pid probe only trusts a match (#716). */
host?: string;
/** What the user asked to build (from the `scope` event). */
intent?: string;
scope?: string;
/** The wrapped agent (from the `session` event). */
driver?: string;
/** The workspace the agent builds in (from the `session` event). */
workspace?: string;
/** The wrapped agent's real session id, once it reports one. */
sessionId?: string;
/** The link shown to jump into the live agent session. */
sessionLink?: string;
/** The session name the agent chose (#326), also its `the-framework/<name>` branch. */
sessionName?: string;
/**
* The branch the run's work is on: folded from `branch` events as the run observes it (#1277),
* and corrected at teardown while the worktree still exists (#799).
*
* Not reliably derivable instead of recorded: a clean run loses its checkout, and the #326
* prompt lets the agent create its own branch, so neither `the-framework/<sessionName>` nor
* the run-id branch is guaranteed to be the one holding the commits.
*/
branch?: string;
/**
* The ticket this run is implementing (#1117), repo-relative (`tickets/<file>.md`).
*
* Set only when the framework picked the ticket itself, so the Overview can show a ticket that is
* being coded right now as `implementing` instead of inferring it from the plan/spike it left
* behind. Absent on every run nobody linked to a ticket.
*/
ticket?: string;
/**
* The queue entry this run was pinned to by the routine's drain (#1253), verbatim.
*
* The durable half of the sweep's pin: the in-memory pin dies with the daemon, and a hands-off
* web run's local process ends at the hand-off, but the meta stays — so the sweep can keep an
* entry off the market while any run that names it is live or has an open PR.
*/
queueEntry?: string;
/** Whether the agent signalled `setReadyForMerge()` (#326): building (false/absent) vs ready (true). */
readyForMerge?: boolean;
/**
* What this session's end-of-session handoff is armed to do (#1102): push its branch, and open
* a draft PR for it. Both start on.
*
* On the meta because the checkboxes that show it live in a different process from the run that
* obeys it, and a tab opened after the run started has no event history to fold — the same
* reason {@link browserStreamPort} is here. Absent means an older run, which the reader treats
* as armed, matching what that run will actually do.
*
* `merge` mirrors the auto-merge arming (#1216, #1382) — display-only, like the rest of this
* field: the run merges off its own config, never off the meta. Absent on records from before
* #1382, which the reader treats as off.
*/
handoff?: {
push: boolean;
pr: boolean;
merge?: boolean;
};
/**
* How the end-of-session handoff reported back (#1455), folded from the `handoff` event.
*
* What lets a list surface — which reads meta, not the event log — tell "ended, still
* publishing" from "ended, published": between a clean `end` and this field, an armed run's
* epilogue is still pushing / opening the PR, exactly the window the session pill calls
* "publishing…" (#1431). Only trustworthy at {@link RunMeta.version} ≥ 2: older records never
* folded the event, so its absence there says nothing and must not read as forever-publishing.
*/
handoffReport?: 'done' | 'skipped' | 'failed';
/**
* How the handoff's merge half went (#1418), folded from the `handoff` event's `merge` field.
*
* What the daemon's CI watch scans for: `watched` is a PR waiting for green that *this* side
* must merge (the repo could not arm GitHub auto-merge), `auto-armed` one GitHub will land by
* itself but whose checks going red is still ours to notice. On the meta because the watch
* reads metas, not event logs, and must survive both the run's process and the daemon's.
* Absent on runs from before this field, and on every run whose handoff had no merge to report.
*/
mergeOutcome?: 'auto-armed' | 'merged' | 'watched' | 'withheld' | 'failed';
/**
* The choice gate the run is currently parked on (#636): set when a `choice` event fires and
* cleared when its `choice-resolved` (or the run's `end`) arrives. Present means the run is
* paused waiting for the user's answer — the second "needs you" source after open PRs (#624).
*/
pendingChoice?: {
id: string;
title: string;
};
/**
* When the run settled and parked on the user (#785), or absent while the agent is working.
*
* Deliberately not a {@link RunStatus} value: the run IS still live while it waits (its
* process is alive, it still takes messages, it still holds the project), and a dozen readers
* key "live" off `status === 'running'`. This is the orthogonal fact — working, or waiting on
* you — which `status` cannot carry because it only changes when the run ends.
*/
settledAt?: string;
/**
* The loopback port the run's browser preview is listening on (#813), or absent when the run
* has no browser. What lets the daemon proxy the pane: the port is allocated per run and the
* dashboard is a different process, so meta is the only place it can learn it.
*/
browserStreamPort?: number;
/**
* Where this run executes (#1050/#1053/#610): `actions` for a GitHub Actions run, `web` for a
* Claude Code cloud session, `remote` when relayed to a connected device (#1067), absent for a
* local run. Persisted so the run view can tell a burst-mode Actions run from a stalled live
* feed, show a cloud run's session link after a reload, and gate the browser pane off (#1053).
*/
target?: 'local' | 'actions' | 'remote' | 'web';
/** The connected device a remote run (#1067) executes on, for the session list + notice after a reload. */
remoteLabel?: string;
/**
* A project-less "topic" run (#1120): started with no project, in a neutral scratch dir with no
* repo. Persisted so a reader can tell it from a project run without the daemon's memory (which a
* restart loses), and so teardown retains vs removes its scratch dir by the same policy as a worktree.
*/
topic?: true;
/**
* The flow this run started under (#1467): `build` for the scope→build orchestration, `prompt`
* for the direct-prompt path (research and transparent runs record `prompt` too). Persisted so a
* continuation (#762) can re-enter the flow its first leg ran — the composer's Resume always
* arrives as a `prompt` start, and without this record a resumed build run ended as a bare
* prompt session (no synthesize framing, no backlog offer). Absent on records from before this
* field, which a reader treats as unknown (the continuation then keeps the prompt path).
*/
kind?: 'build' | 'prompt';
/**
* The model id the current leg's agent was started with (#1438), folded from each leg's
* `session` event — a continuation (#762) may run a different model than the first leg, so
* the latest leg wins rather than the first pinning it. Absent when the leg left the agent
* on its own default (and on records from before this field).
*/
model?: string;
/**
* The project a topic run (#1120) bound itself to (#1121): set from a `bind` event when the
* agent resolves an `await-bind-project` / `await-create-project` gate. Present means the run is
* no longer project-less; the worktree re-home it implies is #1122.
*/
boundProjectId?: string;
}
/**
* The slice of a filesystem {@link RunStore} needs. Mirrors the `LedgerFs` seam
* in ai-autopilot's decisions store: the store logic is pure and testable with an
* in-memory fs, and only {@link nodeStoreFs} touches disk.
*/
export interface StoreFs {
read(path: string): Promise<string>;
write(path: string, contents: string): Promise<void>;
append(path: string, contents: string): Promise<void>;
exists(path: string): Promise<boolean>;
mkdir(path: string): Promise<void>;
/** List a directory's entries (names only). Missing dir yields `[]`. */
readdir(path: string): Promise<string[]>;
}
/** Options for {@link RunStore.open}. */
export interface OpenStoreOptions {
/** The filesystem adapter. Default {@link nodeStoreFs}. */
fs?: StoreFs;
/**
* Truncate any prior log so this is a clean run (MVP: one run per workspace).
* `false` (the default) opens read-only-ish for {@link RunStore.loadEvents} —
* the `--resume` path — and does not clear the log.
*/
fresh?: boolean;
/** The wall-clock start, ISO. Injectable so tests are deterministic. */
now?: string;
/**
* Reads the current time for each appended event, so {@link RunMeta.updatedAt} tracks the last
* event rather than the run's start. Injectable so tests can step it deterministically.
*
* Separate from {@link now} on purpose: `now` is when the run *opened*, and a single timestamp
* cannot answer both questions. Reusing it for appends froze `updatedAt` at `startedAt` for a
* run's whole life, which every reader that orders by recency (the overview, the activity feed,
* the interventions queue) silently sorted on.
*/
clock?: () => string;
/**
* The run's intent (its prompt / request) shown in the dashboard's Runs list. A build run
* later refines this via its `bootstrap` scope event; a `prompt`/`research` run has no scope
* step, so seeding it here is the only way its row shows the prompt instead of "(no prompt)".
*/
intent?: string;
/**
* Who owns this run (#716). Defaults to the current process on this host — the process opening a
* fresh store *is* the run's owner. Injectable so tests can seed a specific (dead) pid.
*/
owner?: RunOwner;
/**
* The run's id, overriding the one derived from {@link OpenStoreOptions.now}. The daemon
* allocates the id before it spawns the run (it names the run's worktree with it, #736) and
* passes it in, so the worktree directory and the run inside it are one string rather than two
* timestamps taken a moment apart. Ignored unless path-safe.
*/
id?: string;
/**
* Reopen the run already at this path instead of starting a new one (#762): keep its event log
* and its original intent, and flip it back to `running` under this process. What makes a
* continued run one row in the history rather than two: the follow-up is a second process, but
* it writes into the same run.
*
* Falls back to a fresh run when there is nothing to reopen.
*/
continueRun?: boolean;
/** Where this run executes (#1053/#610): recorded on the meta so the run view can read it. */
target?: 'local' | 'actions' | 'web';
/** A project-less topic run (#1120): recorded on the meta so a reader can tell it from a project run. */
topic?: boolean;
/** The flow this run started under (#1467): recorded on the meta so a continuation can re-enter it. */
kind?: 'build' | 'prompt';
}
/**
* Fold one event into the running {@link RunMeta}. Pure, so the same derivation
* drives both a live append and reconstructing meta from a replayed log.
*/
export declare function applyEventToMeta(meta: RunMeta, event: FrameworkEvent, at: string): RunMeta;
/** Who owns a live run: its OS pid and the host that pid lives on (#716). */
export interface RunOwner {
pid: number;
host: string;
}
/** Rebuild {@link RunMeta} from a full event log (used when resuming). */
export declare function metaFromEvents(events: readonly FrameworkEvent[], startedAt: string): RunMeta;
/**
* Durable, append-only store for a single run's orchestration events, plus a
* derived {@link RunMeta} 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 declare class RunStore {
private readonly fs;
readonly dir: string;
private readonly clock;
private tail;
private meta;
/**
* The intent a continuation must keep (#762/#1467): a reopened run keeps its original label,
* but a build continuation re-runs the bootstrap, whose `scope` event carries the resume
* message and would relabel the row through {@link applyEventToMeta}'s normal refinement.
* Unset for a fresh run, where the scope event's refinement stands.
*/
private pinnedIntent;
private constructor();
/** The event log path. */
get eventsPath(): string;
/** The meta snapshot path. */
get metaPath(): string;
/**
* Open (creating `.the-framework/` if needed) under the workspace `cwd`. `fresh`
* truncates any prior log for a new run; the default preserves it so a resume
* can {@link loadEvents}.
*/
static open(cwd: string, opts?: OpenStoreOptions): Promise<RunStore>;
/**
* 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 run).
*/
append(event: FrameworkEvent): Promise<void>;
/**
* Flush any queued writes, then archive this run into `runs/` so it shows up in
* the dashboard's history (#303). Both best-effort: persistence must never break
* a run, so an archive failure is logged, not thrown.
*/
close(): Promise<void>;
/** The current derived snapshot. */
snapshot(): RunMeta;
/**
* 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 run
* still replays everything up to the cut. Missing file yields `[]`.
*/
loadEvents(): Promise<FrameworkEvent[]>;
/** Read the persisted meta snapshot, or `undefined` if none/unreadable. */
readMeta(): Promise<RunMeta | undefined>;
private writeMeta;
}
/**
* Put an archived run's history back where a run reads it (#762), so a continued run picks up its
* own log rather than starting empty. The inverse of {@link archiveWorktreeRun}: 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 run (nothing to restore, and its log is newer),
* or when there is no archive. Never throws.
*/
export declare function restoreArchivedRun(repo: string, worktree: string, runId: string, fs?: StoreFs): Promise<boolean>;
/**
* The run ids that have a worktree directory under `.the-framework/worktrees/` (#737). Names
* only, from the filesystem: a directory here IS a run's checkout, and its name is the run id.
* Forgiving — a project that never ran concurrently has no such dir and yields `[]`.
*/
export declare function listWorktreeDirs(cwd: string, fs?: StoreFs): Promise<string[]>;
/**
* Archive a worktree run's history into the *main repo* (#737), returning the meta it archived.
*
* A run writes its `run.json` / `events.jsonl` inside its own worktree (#736), so deleting that
* worktree would delete the run'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 committed `sessions/` (#1179) instead of the transient
* `runs/`. It is this copy, not the one the run left in its own worktree, that is meant to last:
* every run in a git repo gets a worktree, so this is the only archive of it that outlives the
* checkout, and committing it is what makes the history survive `git clean -fdx`. The worktree's
* own copy deliberately stays untracked — it would otherwise be committed onto the run'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 reconcileOrphanedRuns} 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 declare function archiveWorktreeRun(worktree: string, repo: string, fs?: StoreFs, branch?: string, user?: string): Promise<RunMeta | undefined>;
/**
* The archived log + meta paths of one run, 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 declare function archivedRunPaths(cwd: string, runId: string, fs?: StoreFs): Promise<string[]>;
/**
* List a project's archived runs, most-recent first: every user's committed `sessions/` plus the
* legacy `runs/`. The id sorts chronologically so no timestamp parse is needed. Missing or
* unreadable dir/entries are skipped, never thrown.
*/
export declare function listRuns(cwd: string, fs?: StoreFs): Promise<RunMeta[]>;
/**
* Reconcile runs a dead process left marked `running` — the live `run.json`, an archived
* `runs/*.json`, or a run inside a worktree. Such a run 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 run, never throws.
*
* A run whose pid is alive on this host is left alone (#926). This used to flip every `running`
* meta on the assumption that a fresh daemon drives no in-flight run, which holds only while
* exactly one daemon ever boots: a second one (and before #922, every failed `framework --daemon`
* spawned one) marked genuinely live runs 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 declare function reconcileOrphanedRuns(cwd: string, fs?: StoreFs, isAlive?: (pid: number) => boolean): Promise<number>;
/**
* 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 RunMeta.host}
* before trusting a result. A recycled pid (another process reusing a dead run's number) reads as
* alive — an accepted, vanishingly rare miss on a single dev box.
*/
export declare function isPidAlive(pid: number): boolean;
/**
* The live (in-progress) run's meta snapshot from `.the-framework/run.json`, or
* `undefined` when none/unreadable. Unlike {@link listRuns} (which reads the
* archived `runs/` copies written on close), this is the run 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 run 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 reconcileOrphanedRuns}. A run whose
* meta predates this field (no `pid`) is left untouched — the boot reconcile still catches it.
*/
export declare function readLiveMeta(cwd: string, fs?: StoreFs, isAlive?: (pid: number) => boolean): Promise<RunMeta | undefined>;
/**
* A live run plus the checkout it is running in (#738). Since #736 a run lives in its own
* worktree, so a project's live run is no longer a single thing and no longer sits at the
* project path: `cwd` says which checkout to read that run's git/file status from.
*/
export interface LiveRun extends RunMeta {
/** The run's own checkout: a worktree under `.the-framework/worktrees/`, or the repo root. */
cwd: string;
}
/**
* Every live run of a project (#738): the list variant of {@link readLiveMeta}.
*
* A run started from the dashboard gets its own worktree (#736) and writes its `run.json`
* inside it, so the project path alone no longer sees any of them. This looks in both places:
* each `.the-framework/worktrees/*` 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 run
* from before #736 lives.
*
* Each candidate goes through {@link readLiveMeta}, so a stale run self-heals exactly as it
* did. Newest first, by id. Never throws: an unreadable worktree is skipped.
*/
export declare function readLiveMetas(cwd: string, fs?: StoreFs, isAlive?: (pid: number) => boolean): Promise<LiveRun[]>;
/**
* Read one archived run'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 RunStore.loadEvents}).
*/
export declare function loadRunEvents(cwd: string, id: string, fs?: StoreFs): Promise<FrameworkEvent[] | undefined>;
/** A {@link StoreFs} backed by `node:fs/promises`. See {@link nodeFs}. */
export declare function nodeStoreFs(): StoreFs;
/**
* 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": a run was only ever copied into `runs/` on its way out.
* Continuing a run (#762) breaks that — the run has an archived copy from its first leg AND is
* live again — and keeping the archive showed a running run 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 declare function readAllRuns(cwd: string, fs?: StoreFs): Promise<RunMeta[]>;
/**
* One run's meta by id, live copy winning over archived — {@link readAllRuns}'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 declare function findRun(cwd: string, runId: string, fs?: StoreFs): Promise<RunMeta | undefined>;
/**
* 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 RunStore.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 declare function readEventLog(cwd: string, fs?: StoreFs): Promise<FrameworkEvent[]>;
//# sourceMappingURL=run-store.d.ts.map