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.
420 lines • 22.5 kB
JavaScript
import { Bootstrap, DockerRunner, LocalRunner, LoopEngine, dockerAvailable, builtinFrameworkPresetRegistry, mergeChecklists, serveCheck, } from '@gemstack/ai-autopilot';
import { snapshotWorkspace } from './sandbox.js';
import { composeRunSystem } from './system-prompt.js';
import { createRunControls, emitSessionStart, endStopDetail } from './run-telemetry.js';
import { createTurnSignalEmitter } from './turn-gate.js';
import { drainGates, runChatPhase } from './await-gate.js';
import { leaveResumeNote, runTodoLoop } from './todo-loop.js';
import { continueAfterChoice, decideDeploy, deployWith, domainLoopChecklist, driverBuild, driverImprove, driverLoopPrompts } from './steps.js';
import { OPEN_LOOP_MODES } from './events.js';
import { errorMessage } from './error-message.js';
/**
* The framework's default full-fledged pass budget. Higher than ai-autopilot's
* base of 3 because a from-scratch build spends its first pass or two just
* bootstrapping an empty workspace before there is anything to polish (#182).
*/
export const DEFAULT_MAX_PASSES = 5;
/**
* Run the whole turnkey flow: detect the framework preset, frame the wrapped
* agent with its framework skill (page builder + docs), then drive ai-autopilot's `Bootstrap`
* (scope → build → full-fledged loop → deploy) entirely *through*
* the driver (option A). Every phase, plus the agent's own progress, streams as
* a {@link FrameworkEvent}. Reversible: swap in a real deploy target, or a
* different `Driver`, without touching this wiring.
*/
export async function runFramework(opts) {
const events = [];
const emit = (event) => {
events.push(event);
if (opts.onEvent) {
try {
opts.onEvent(event);
}
catch (err) {
console.error('[framework] onEvent threw; ignoring:', err);
}
}
};
// 1. Detect the framework the project already uses. Detection only narrates
// ("Detected Vike") and rides the result — nothing about it reaches the agent's
// prompt (#547).
const signals = opts.signals ?? {};
const { preset, detection } = builtinFrameworkPresetRegistry().select(signals);
const domainPreset = opts.preset;
// The built-in #326 system prompt + any user SYSTEM.md are the whole prompt. Only
// the template's system half is used here: each Bootstrap step composes its own
// prompt around the intent, so the user-prompt slot stays with the steps.
// `tf.params.autopilot` reflects the run's autopilot mode (#325).
const tf = {
prompt: opts.intent,
params: { autopilot: opts.modes?.includes('autopilot') ?? false, ...(opts.eco ? { eco: opts.eco } : {}) },
};
// The "read" half of the bind mechanism (#1121/#1129): a topic run's channel lists the projects
// it can bind to. Read through the same injected seam the gate resolves against, so no `node:fs`
// reaches this path; absent for a non-topic run, which gets no bind block at all.
const topicProjects = opts.topic && opts.bind ? (await opts.bind.listProjects()).map(p => p.path) : undefined;
// One assembly path for the whole system channel (#501), shared with the
// direct-prompt path so the two can never drift (the drift behind #500).
const system = composeRunSystem({
antiLazyPill: opts.antiLazyPill,
browser: opts.browser,
handsOff: opts.driver.handsOff === true,
topic: opts.topic,
...(topicProjects ? { topicProjects } : {}),
transparent: opts.transparent,
user: opts.systemPrompt,
tf,
context: opts.context,
});
emitSessionStart({ emit, driver: opts.driver, cwd: opts.cwd, sessionLink: opts.sessionLink, model: opts.model });
// Surface the exact system prompt the agent runs under (#343). Nothing is read
// off disk and appended after this, so the text is the whole of it (#547). The
// per-turn user prompts ride along as `driver` `start` events, so the dashboard
// can show every prompt sent.
if (system)
emit({ kind: 'system-prompt', text: system });
emit({
kind: 'log',
message: `Detected ${detection.framework ?? preset.framework} (confidence ${detection.confidence})`,
});
if (domainPreset) {
const modeNote = opts.modes?.length ? ` (modes: ${opts.modes.join(', ')})` : '';
emit({
kind: 'log',
message: `Domain preset: ${domainPreset.title}${modeNote}; ${domainPreset.loops.length}-loop review policy in effect`,
});
// Surface the run's active modes as read-only checkboxes on the dashboard (#272).
emit({ kind: 'modes', all: OPEN_LOOP_MODES, active: opts.modes ?? [] });
}
// The run's abort plumbing and driver-event sink: the caller's signal composed with
// the budget (#322), consumption (#529), and plan-decline (#358) self-stops.
const { runSignal, onDriverEvent, consumptionTrip, budgetController, consumptionController, declineController } = createRunControls({
emit,
signal: opts.signal,
sessionLink: opts.sessionLink,
budgetUsd: opts.budgetUsd,
consumptionGate: opts.consumptionGate,
});
// 2. One driver session for the whole run; each prompt is a fresh invocation.
// A continuation (#1467) resumes the stopped leg's conversation instead of starting anew.
const resuming = typeof opts.resumeSessionId === 'string' && opts.resumeSessionId.length > 0;
const session = await opts.driver.start({
cwd: opts.cwd,
system,
...(opts.model ? { model: opts.model } : {}),
...(resuming ? { resumeSessionId: opts.resumeSessionId } : {}),
signal: runSignal,
onEvent: onDriverEvent,
});
// The domain preset's review policy (exposed on the result) and the review
// checklist it drives — absent without a preset (#1372): the agent is a black
// box, so nothing reviews its work unless the user opted in.
const { loop, reviewChecklist } = buildReview(session, domainPreset, {
...(opts.buildEvent ? { buildEvent: opts.buildEvent } : {}),
signal: runSignal,
emit,
});
// Boot-and-serve gate: provision a runner so the checklist can gate on the app
// actually running. Local adopts (never deletes) the driver's cwd in place; docker
// sandboxes the check in a throwaway container (#229). An injected runner wins over both.
const sandbox = opts.sandbox ?? 'local';
const s = opts.serve;
let runner;
if (s)
runner = opts.runner ?? (await provisionServeRunner(sandbox, opts.cwd, s, emit));
const checklist = withServeCheck(reviewChecklist, runner, s, {
sandbox,
cwd: opts.cwd,
injectedRunner: opts.runner !== undefined,
emit,
});
// A real driver writes files to the workspace, so the build/improve steps can
// detect an empty workspace and hard-scaffold it (#182). The fake driver writes
// nothing (its whole workspace is always "empty"), so it opts out to stay
// deterministic.
const verifyWorkspace = opts.driver.name !== 'fake';
const workspaceOpt = verifyWorkspace ? { verifyWorkspace: true } : {};
// The shared deps of every agent-facing gate.
const gateDeps = {
...(opts.requestChoice ? { requestChoice: opts.requestChoice } : {}),
emit,
signal: runSignal,
onDecline: () => declineController.abort(new Error('[framework] plan declined')),
// Topic runs only (#1121): resolves an await-bind-project / await-create-project gate.
...(opts.bind ? { bind: opts.bind } : {}),
};
// A hand-off driver (#1225): the prompt leaves this machine and the reply never comes back,
// so the build prompt is the entire run. Every phase after it — the review checklist,
// improving against its blockers, the backlog gate, live chat — would be reading
// the driver's own "handed off to <url>" summary as if the agent had written it. That is
// what put a verdict-is-missing complaint and an unanswerable "Start the next
// backlog item?" call on a dashboard whose agent was somewhere else entirely. So the phases
// are dropped rather than fed: Bootstrap skips its whole loop when no `checklist` step is
// given, which leaves scope -> build and nothing after it. A run with no preset and no
// serve config has no checklist either (#1372), and skips the loop the same way.
const handsOff = opts.driver.handsOff === true;
let preview;
try {
const bootstrap = new Bootstrap({
maxPasses: opts.maxPasses ?? DEFAULT_MAX_PASSES,
signal: runSignal,
onEvent: (event) => emit({ kind: 'bootstrap', event }),
steps: {
scope: () => ({ scope: opts.scope ?? 'full', intent: opts.intent }),
// Resuming (#1467): the intent IS the continuation message and goes out verbatim — the
// resumed transcript already carries the build framing, so re-rendering it would stack
// a second scope→build preamble onto a conversation that lived through the first.
build: agentAwaitGate(driverBuild(session, { ...workspaceOpt, ...(resuming ? { prompt: (intent) => intent } : {}) }), session, gateDeps),
...(handsOff || !checklist ? {} : { checklist, improve: driverImprove(session, workspaceOpt) }),
...(opts.deploy
? {
deploy: opts.deployTarget
? deployWith(opts.deploy, opts.deployTarget)
: decideDeploy(opts.deploy),
}
: {}),
},
});
const result = await bootstrap.run();
// The run controls (budget #322, decline #358, quota #529) abort between phases.
// The review loop used to be the phase after the build and observed the abort for
// free; with no checklist step the bootstrap can settle without ever re-checking
// (#1372), so the run must look for itself before treating the result as a success.
if (runSignal.aborted) {
throw runSignal.reason instanceof Error ? runSignal.reason : new Error('[framework] run stopped');
}
// The backlog loop (#323): with the build settled, consume the agent's own
// TODO backlog one gated entry per turn until it is empty. Default on for
// real drivers (the fake demo writes no backlog and must stay deterministic;
// its reused tmp workspace could also carry stale files). The run signal
// (Stop / budget cap #322) and the item cap bound it for unattended runs.
let todo;
if (!handsOff && (opts.todoLoop ?? opts.driver.name !== 'fake')) {
todo = await runTodoLoop({
session,
cwd: opts.cwd,
emit,
requestChoice: opts.requestChoice,
signal: runSignal,
maxItems: opts.todoMaxItems,
});
}
// The serve gate boots the app only to check it, then stops it. When the
// caller opts in (keepAlive), boot it once more after success and leave it up
// so the user can open it; the caller owns tearing it down (Ctrl+C). Failure
// to boot is non-fatal. Default off, so a programmatic run never leaks a
// process a caller that ignores `preview` would never stop.
if (runner && s?.keepAlive)
preview = await startAppPreview(runner, s, emit);
// Live chat (#714): with the build settled, take the user's own messages, each continuing
// the same session — draining what queued and ending on idle (#1390), or parked until Stop
// for a terminal-dashboard run (stayOpenChat).
// A hand-off run has no session here to continue: the CLI can start a cloud session and
// pull one back, but it cannot send a second message to one, so staying open would offer
// a composer whose every message answers itself. The run ends instead, with the link.
if (opts.messages && !handsOff) {
await runChatPhase(session, opts.messages, { text: '' }, {
...(opts.requestChoice ? { requestChoice: opts.requestChoice } : {}),
emit,
emitTurnSignals: createTurnSignalEmitter(emit),
signal: runSignal,
...(opts.recordMessage ? { recordMessage: opts.recordMessage } : {}),
}, opts.stayOpenChat === true);
}
// Say why the run stops here, so a finished hand-off does not read as a run that gave up
// one phase in. The link itself is already on the driver's `cloud <url>` action.
if (handsOff)
emit({ kind: 'log', message: 'Handed off: the rest of this run happens in its own session, which opens its own pull request.' });
emit({ kind: 'end', ok: true });
return { result, detection, events, ...(preview ? { preview } : {}), ...(loop ? { loop } : {}), ...(todo ? { todo } : {}) };
}
catch (err) {
const { stopped, detail } = await endStopDetail({
err,
...(opts.signal ? { signal: opts.signal } : {}),
budgetController,
consumptionController,
declineController,
consumptionTrip,
...(opts.budgetUsd != null ? { budgetUsd: opts.budgetUsd } : {}),
leaveResumeNote: () => leaveResumeNote(opts.cwd, events, emit),
});
emit({ kind: 'end', ok: false, ...(stopped ? { stopped: true } : {}), detail });
throw err;
}
finally {
await session.dispose();
// Keep the runner alive only when it owns a live preview handed to the caller.
if (runner && !preview)
await runner.dispose();
}
}
/**
* Materialize the domain preset's review policy against the run's driver, and the review
* checklist it drives (#252) — each pass fires the preset's review chain through the
* driver. Without a preset there is no review checklist at all (#1372): the agent is
* treated as a black box, so the run reviews only what the user opted into. The build
* event kind: an explicit run choice wins, else the preset's own default, else
* `major-change` — how a `bug-fix` run reaches the preset's bug-fix loop (#265). The
* `loop` is returned so the caller can expose it on the result.
*/
function buildReview(session, domainPreset, ctx) {
const loop = domainPreset
? new LoopEngine({
loops: [...domainPreset.loops],
prompts: driverLoopPrompts(session, domainPreset.prompts, { signal: ctx.signal }),
})
: undefined;
const buildEvent = ctx.buildEvent ?? domainPreset?.defaultEvent ?? 'major-change';
const reviewChecklist = loop ? domainLoopChecklist(loop, { kind: buildEvent }) : undefined;
if (loop && domainPreset) {
ctx.emit({ kind: 'log', message: `Review policy: the ${domainPreset.title} loop drives the ${buildEvent} review` });
}
return { loop, reviewChecklist };
}
/**
* Union the review checklist with a boot-and-serve gate (#229) when the run has a runner:
* `serveCheck` verifies the app actually boots, and `mergeChecklists` runs it alongside
* the review. A docker sandbox re-seeds the container from the host source before every
* check (the build writes to the host each pass); local reads the host dir live, so it
* needs no sync. Without a runner/serve the review checklist stands alone — which may be
* no checklist at all (#1372: no preset, no serve → nothing gates the build).
*/
function withServeCheck(review, runner, serve, ctx) {
if (!runner || !serve)
return review;
const check = serveCheck(runner, {
serve: serve.command,
...(serve.install ? { install: serve.install } : {}),
...(serve.build ? { build: serve.build } : {}),
...(serve.port !== undefined ? { port: serve.port } : {}),
...(serve.waitMs !== undefined ? { waitMs: serve.waitMs } : {}),
...(serve.healthPath ? { healthPath: serve.healthPath } : {}),
onProgress: message => ctx.emit({ kind: 'log', message: `serve: ${message}` }),
});
const serveStep = ctx.sandbox === 'docker' && !ctx.injectedRunner ? syncThenServe(runner, ctx.cwd, check, ctx.emit) : check;
return review ? mergeChecklists(review, serveStep) : serveStep;
}
/**
* The agent-authored await gate (#337 / #339): the turn-boundary counterpart to the
* framework-emitted plan-approval gate (#304). When a build turn ends by asking the
* user — an `await-choices` (pick one), `await-multiselect` (pick any), or
* `await-confirmation` (approve/decline a plan, #358) block per
* {@link AWAIT_PROTOCOL}, e.g. the #326 alternatives flow or the [Research] preset (#331)
* — rather than finishing, show it, wait for the answer, and re-prompt the driver to
* continue from that decision. A no-op unless a {@link RunFrameworkOptions.requestChoice}
* handler is wired (headless byte-identical), and unless the agent actually stopped to
* ask (the common case returns straight through). Bounded so an agent that keeps asking
* can't loop forever.
*/
function agentAwaitGate(base, session, deps) {
return async (ctx) => {
const { requestChoice, emit } = deps;
// Non-blocking signals the agent emitted this turn: markdown views (#441) pushed to the
// rail, and the #326 lifecycle signals (session name, ready-for-merge) that flip the run's
// dashboard status. None stop the turn.
const emitTurnSignals = createTurnSignalEmitter(emit);
let run = await base(ctx);
emitTurnSignals(run.text);
// The run controls (budget #322, quota #529) abort on the build turn's own usage.
// The build can be the bootstrap's last step (#1372: no checklist without a preset or
// serve config), so a stop the loop would once have caught must throw here — otherwise
// the bootstrap settles an aborted run as done.
const throwIfStopped = () => {
if (!deps.signal?.aborted)
return;
throw deps.signal.reason instanceof Error ? deps.signal.reason : new Error('[framework] run stopped');
};
// Headless: nobody to ask, so the build's turn stands as it is rather than auto-answering
// its own question. (The prompt paths differ here — they resolve to the recommended pick.)
if (!requestChoice) {
throwIfStopped();
return run;
}
const drained = await drainGates(run, { ...deps, emitTurnSignals }, (question, answer) => continueAfterChoice(session, ctx, question, answer));
// A declined plan (#358) ends the build here rather than re-prompting: the user takes over
// with fresh instructions (e.g. a new run from the dashboard).
if (drained.declined)
deps.onDecline?.();
// The agent kept asking past the limit: proceed with the latest turn rather than loop.
else if (drained.exhausted)
emit({ kind: 'log', message: 'Proceeding with the build (await limit reached).' });
throwIfStopped();
return drained.turn;
};
}
/**
* Boot the generated app in the adopted runner and keep it serving. Reuses the
* same {@link ServeConfig} the serve gate used (deps are already installed from
* the gate), so this only `start`s the server and `preview`s the port. Returns a
* handle that stops the app and frees the runner; on any failure it narrates and
* returns `undefined` so a run never fails just because the demo preview didn't
* come up.
*/
async function startAppPreview(runner, serve, emit) {
if (!runner.start || !runner.preview)
return undefined;
let proc;
try {
proc = await runner.start(serve.command);
const { url } = await runner.preview({
port: serve.port ?? 3000,
waitMs: serve.waitMs ?? 15_000,
});
emit({ kind: 'preview', url, command: serve.command });
let stopped = false;
return {
url,
command: serve.command,
stop: async () => {
if (stopped)
return;
stopped = true;
try {
await proc?.stop();
}
finally {
await runner.dispose();
}
},
};
}
catch (err) {
emit({ kind: 'log', message: `preview: could not boot the app (${errorMessage(err)})` });
// Leave cleanup to the caller's finally (runner.dispose stops leftovers).
return undefined;
}
}
/**
* Provision the runner the serve gate verifies in (#229). `local` adopts the host
* cwd in place (dispose leaves it); `docker` boots a throwaway container the check
* seeds and tears down. Fails fast with a clear message when docker is requested
* but not reachable, so the run never limps on unsandboxed by surprise.
*/
async function provisionServeRunner(sandbox, cwd, serve, emit) {
if (sandbox === 'docker') {
if (!(await dockerAvailable())) {
throw new Error('sandbox: --sandbox docker was requested but Docker is not reachable (need a running daemon and the `docker` CLI on PATH).');
}
emit({ kind: 'log', message: 'sandbox: booting a Docker container for the serve check' });
// preview() publishes the container's fixed port, so it must match the port the
// serve check previews on (serve.port, default 3000).
return new DockerRunner({ previewPort: serve.port ?? 3000 }).boot();
}
return new LocalRunner().adopt(cwd);
}
/**
* Wrap a serve check so the sandbox is re-seeded with the host source before it
* runs. The build happens on the host in this slice, so an isolated container has
* to be synced each pass to see what the agent just wrote.
*/
function syncThenServe(runner, cwd, check, emit) {
return async (ctx) => {
const files = await snapshotWorkspace(cwd);
for (const [path, contents] of Object.entries(files))
await runner.fs.write(path, contents);
emit({ kind: 'log', message: `serve: synced ${Object.keys(files).length} file(s) into the sandbox` });
return check(ctx);
};
}
//# sourceMappingURL=run.js.map