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.
137 lines • 7.23 kB
JavaScript
import { hasSessionIdPlaceholder, resolveSessionLink } from './session-link.js';
import { UsageMeter } from './usage.js';
/**
* Emit the run's opening `session` event. A literal link is shown right away; a
* templated one (`.../{sessionId}`) can only resolve once the driver reports its
* session id, so it waits for the `session-update` from {@link createDriverEventHandler}.
*/
export function emitSessionStart(opts) {
const literal = opts.sessionLink && !hasSessionIdPlaceholder(opts.sessionLink) ? opts.sessionLink : undefined;
opts.emit({
kind: 'session',
driver: opts.driver.name,
workspace: opts.cwd,
fake: opts.driver.name === 'fake',
...(literal ? { sessionLink: literal } : {}),
...(opts.model ? { model: opts.model } : {}),
});
}
/**
* Watch the driver's black box (#165) and turn it into the run's stream: surface the
* real session id as `session-update` once known (that is the honest handle a UI links
* to, and it changes per prompt, so re-emit), fold each turn's usage into the run total,
* and trip the two self-stops.
*
* Both stops fire *after* the turn that crossed them: its cost is already spent, so the
* point is to stop the next one. Each is signalled once, and the run's `AbortSignal.any`
* composition carries it downstream. An agent that reports no price leaves `costUsd`
* undefined and so can never trip the budget cap (#540). A consumption gate that throws
* is treated as "carry on": an unreadable quota must not stop the work (#519), and the
* gate is answered from a cached reading because a live one spawns the agent CLI (~5s).
*/
export function createDriverEventHandler(opts) {
const { emit, budgetController, consumptionController } = opts;
let lastSessionId;
let consumptionTrip;
const usage = new UsageMeter();
const onDriverEvent = (event) => {
// The turn-start id announcement (#1322) is consumed here, not forwarded: it exists so the
// `session-update` below lands before a Stop or a crash can lose the turn, and a transcript
// row repeating an id the very next event also carries would only be noise.
if (event.type === 'session') {
if (event.sessionId !== lastSessionId) {
lastSessionId = event.sessionId;
const link = opts.sessionLink ? resolveSessionLink(opts.sessionLink, event.sessionId) : undefined;
emit({ kind: 'session-update', sessionId: event.sessionId, ...(link ? { sessionLink: link } : {}) });
}
return;
}
emit({ kind: 'driver', event });
if (event.type !== 'result')
return;
if (event.sessionId && event.sessionId !== lastSessionId) {
lastSessionId = event.sessionId;
// A driver that knows its session's real URL (#1317, the cloud hand-off) beats the
// `--session-link` template, whose Claude default is the generic entry point.
const link = event.sessionLink ?? (opts.sessionLink ? resolveSessionLink(opts.sessionLink, event.sessionId) : undefined);
emit({ kind: 'session-update', sessionId: event.sessionId, ...(link ? { sessionLink: link } : {}) });
}
if (!event.usage)
return;
usage.add(event.usage);
const totals = usage.totals();
emit({ kind: 'usage', ...totals, ...(opts.budgetUsd != null ? { budgetUsd: opts.budgetUsd } : {}) });
if (opts.budgetUsd != null && totals.costUsd !== undefined && totals.costUsd >= opts.budgetUsd && !budgetController.signal.aborted) {
emit({ kind: 'log', message: `Budget reached: $${totals.costUsd.toFixed(4)} of $${opts.budgetUsd} — stopping the session.` });
budgetController.abort(new Error('[framework] budget reached'));
}
if (opts.consumptionGate && !consumptionController.signal.aborted) {
let reached = null;
try {
reached = opts.consumptionGate();
}
catch (err) {
console.error('[framework] consumptionGate threw; carrying on:', err);
}
if (reached) {
consumptionTrip = reached;
emit({ kind: 'log', message: `Quota boundary reached (${reached}) — pausing the session.` });
consumptionController.abort(new Error('[framework] quota boundary reached'));
}
}
};
return { onDriverEvent, consumptionTrip: () => consumptionTrip };
}
/**
* Compose the run's signal and wire its driver-event handler in one place. The caller's
* signal is OR'd (via {@link AbortSignal.any}) with three self-stops — the budget cap
* (#322), a spent consumption window (#529), and a declined plan (#358) — so anything
* downstream that watches `runSignal` stops the same way regardless of which fired.
* Shared by the build (`run.ts`) and direct-prompt (`prompt-run.ts`) paths.
*/
export function createRunControls(opts) {
const budgetController = new AbortController();
const declineController = new AbortController();
const consumptionController = new AbortController();
const runSignal = AbortSignal.any([
...(opts.signal ? [opts.signal] : []),
budgetController.signal,
declineController.signal,
consumptionController.signal,
]);
const handler = createDriverEventHandler({
emit: opts.emit,
sessionLink: opts.sessionLink,
budgetUsd: opts.budgetUsd,
consumptionGate: opts.consumptionGate,
budgetController,
consumptionController,
});
return { ...handler, runSignal, budgetController, consumptionController, declineController };
}
/**
* Classify why a run's turn loop threw and render the `end` event's `detail`. A caller
* interrupt, a budget cap (#322), a declined plan (#358), or a spent consumption window
* (#529) are all clean stops; anything else is a real failure. The resume note is written
* here (once `paused` is known) rather than at the trip, because it is file I/O racing the
* run unwinding. Shared so the two run paths can never disagree on what "stopped" means.
*/
export async function endStopDetail(opts) {
const callerAborted = opts.signal?.aborted === true;
const budgetStopped = opts.budgetController.signal.aborted && !callerAborted;
const declined = opts.declineController.signal.aborted;
const paused = opts.consumptionController.signal.aborted && !callerAborted;
const stopped = callerAborted || opts.budgetController.signal.aborted || declined || opts.consumptionController.signal.aborted;
const resumeNote = paused ? await opts.leaveResumeNote() : undefined;
const detail = declined
? 'plan declined'
: budgetStopped
? `budget reached ($${opts.budgetUsd})`
: paused
? `quota boundary reached${opts.consumptionTrip() ? ` (${opts.consumptionTrip()})` : ''}${resumeNote ? `; will resume from ${resumeNote}` : ''}`
: opts.err instanceof Error
? opts.err.message
: String(opts.err);
return { stopped, detail };
}
//# sourceMappingURL=run-telemetry.js.map