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.
170 lines • 11.3 kB
TypeScript
import { type ChildProcess } from 'node:child_process';
import type { FrameworkEvent } from './events.js';
import type { StartAgentKind, StartAgentOptions, StartAgentResult, AddProjectResult } from './dashboard/index.js';
import type { EventsSource, RemoteAgents } from './dashboard/rpc-serve.js';
import { type PreflightResult } from './preflight.js';
import { type DriverName } from './driver-names.js';
/**
* 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 declare function resolveSpawnBin(explicitBinPath: string | undefined): string;
/**
* 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 declare function cleanupTimedOutWorktree(repo: string, agentId: string, err: unknown): Promise<void>;
/** Spawn a detached, unref'd framework child (`node <binPath> --agent <specPath>`) that outlives us. */
export declare function spawnDetached(binPath: string, specPath: string, stderrFile?: string): ChildProcess;
/** Where a spawned agent's stderr lands (#1261), so a child that dies at boot leaves a trace. */
export declare function agentStderrPath(cwd: string): string;
/**
* 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 declare function markFailedStart(cwd: string, agentId: string, intent: string, detail: string): Promise<boolean>;
/** Whether an agent's failure detail names a transient transport error (#1281). */
export declare function isTransientAgentFailure(detail: string | undefined): boolean;
/**
* 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 declare function lastAgentFailureDetail(eventsJsonl: string): string | undefined;
/** How many times a transiently-dead agent is continued before its failure stands (#1281). */
export declare const MAX_TRANSIENT_RETRIES = 2;
export declare function delay(ms: number): Promise<void>;
/**
* 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 declare function terminate(pid: number, graceMs: number): Promise<boolean>;
/**
* 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 declare function waitOutSlots(keys: readonly string[], slots: {
activeAgents: Map<string, number>;
retiring: Map<string, Promise<void>>;
}, timeoutMs: number): Promise<void>;
/**
* What the previous leg of an agent says about itself, for {@link waitOutFinishedLeg}. `unknown` is
* the honest third answer — the leg has no readable state *this instant* — and is deliberately
* not folded into either of the other two.
*/
export type FinishedLegState = 'ended' | 'running' | 'unknown';
/**
* 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 declare function waitOutFinishedLeg(key: string, slots: {
starting: Set<string>;
activeAgents: Map<string, number>;
retiring: Map<string, Promise<void>>;
}, readLegState: () => Promise<FinishedLegState>, graceMs: number): Promise<void>;
/** Inputs to {@link createProjectRuntime}. */
export interface ProjectRuntimeOptions {
/** The daemon's home workspace; a run/preview with no project id targets it. */
cwd: string;
/** Env for the registry lookups (#393). */
env: NodeJS.ProcessEnv;
/** The CLI entry to spawn runs with (#345); undefined uses `process.argv[1]`. */
binPath?: string | undefined;
/** The pause before a transient-death retry (#1281); undefined uses {@link TRANSIENT_RETRY_DELAY_MS}. A test seam. */
retryDelayMs?: number | undefined;
/** How a start checks the agent can run (#1326); undefined runs the real {@link preflight}. A test seam. */
driverPreflight?: ((driver: DriverName) => Promise<PreflightResult>) | undefined;
}
/** The per-project agent + preview surface the dashboard drives, plus its teardown. */
export interface ProjectRuntime {
onStart: (prompt: string, kind: StartAgentKind, options?: StartAgentOptions, targetProjectId?: string) => Promise<StartAgentResult>;
onAddProject: (path: string, directory: boolean) => Promise<AddProjectResult>;
/** The live event stream for an agent this daemon is relaying from a device (#1067), else undefined
* so `onEvents` falls back to tailing the on-disk log. Wired as the dashboard's events source. */
remoteEventsSource: EventsSource;
/** Tail a relay-started agent's on-disk events (#1067): the daemon's `/_relay/events` endpoint uses
* it to stream one agent back to whichever daemon relayed it here. */
tailRelayEvents: (agentId: string, onEvent: (event: FrameworkEvent) => void) => () => void;
/** The relayed-agent lookup the dashboard's read RPCs consult (#1067 slice 2): which device a remote
* run runs on, so a run-scoped RPC forwards there instead of resolving a local checkout. */
remoteAgents: RemoteAgents;
/** 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. */
onRelayRpc: (fn: string, args: unknown[]) => Promise<unknown>;
/** Live agents on a project (#685), so a background job can tell an idle project from a busy one. */
activeAgentCount: (targetProjectId: string) => number;
/**
* The agent ids this daemon is still responsible for: spawning, running, or mid-retirement.
*
* The background worktree sweep asks, because an agent's meta flips to `done` a beat *before* its
* teardown archives and reclaims the checkout — so a sweep landing in that window would race
* the teardown for the same directory. "Not live" on disk is not the same as "the daemon is
* finished with it", and this is the difference.
*/
busyAgentIds: () => ReadonlySet<string>;
/**
* Stop the agents this daemon spawned. Returns how many were stopped. Called on shutdown, before
* the previews go.
*/
stopAgents: (graceMs?: number) => Promise<number>;
/** Stop every live preview so their dev servers do not outlive the daemon (#475). */
dispose: () => Promise<void>;
}
/**
* 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 declare function createProjectRuntime({ cwd, env, binPath, retryDelayMs, driverPreflight }: ProjectRuntimeOptions): ProjectRuntime;
//# sourceMappingURL=daemon-runtime.d.ts.map