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.
252 lines • 13.9 kB
JavaScript
import { MAX_AWAIT_ROUNDS, continuationPrompt, parseAwaitGate, stopMessage } from './turn-gate.js';
import { pickedIds } from './events.js';
/**
* Resolve one parsed await gate (#337) to the user's answer text, ready to seed the continuation
* prompt: emits the `choice`, parks for the pick (or the headless/abort fallback), and maps the
* picked id(s) back to label(s). Round 0 keeps a stable gate id; later rounds get a unique one so
* a dashboard never confuses a re-ask with the answer it just resolved. Shared by the build's
* `agentAwaitGate`, the direct prompt path, and the backlog loop (#323).
*
* One path, where there were four. A gate that takes several picks answers with the labels it got
* (or `(none)`); every other gate answers with the one label picked. What used to distinguish an
* approval or a browser hand-off from an ordinary question was the options the agent wrote, and
* that is all it is again.
*
* The answer also carries whether it *ends* the session, which is read off the option the user
* picked rather than off the gate: on a multi-select one stopping pick among several is still a
* stop, because an answer that says "stop" is not softened by the answers next to it.
*/
export async function resolveAwaitGate(gate, round, deps) {
const signalOpt = deps.signal ? { signal: deps.signal } : {};
const choiceOpt = deps.requestChoice ? { requestChoice: deps.requestChoice } : {};
const id = round === 0 ? 'await-choices' : `await-choices-${round}`;
if (gate.multi) {
const ids = await requestMultiSelect({ id, title: gate.title, options: gate.options, emit: deps.emit, ...choiceOpt, ...signalOpt });
const picked = gate.options.filter(o => ids.includes(o.id));
const labels = picked.map(o => o.label);
return { answer: labels.length ? labels.join(', ') : '(none)', stop: picked.some(o => o.stop === true) };
}
const pickedId = await requestChoices({
id,
title: gate.title,
options: gate.options,
...(gate.recommended ? { recommended: gate.recommended } : {}),
...(gate.file ? { file: gate.file } : {}),
emit: deps.emit,
...choiceOpt,
...signalOpt,
});
const picked = gate.options.find(o => o.id === pickedId);
return { answer: picked?.label ?? pickedId, stop: picked?.stop === true };
}
/**
* Resolve the await gates (#337) a turn ended on: pick the answer, continue with it, repeat until
* the agent stops asking, an answer says to stop (#358), or the {@link MAX_AWAIT_ROUNDS} cap trips.
* Returns the settled turn plus which of those ended it.
*
* Every path that runs gates shares this loop: the opening prompt, each chat message, and
* the build's `agentAwaitGate`. They differ only in how a turn is continued — a raw
* `session.prompt`, or a pass that carries its own continuation — which is what
* `continueWith` is. Keeping one loop is what stopped the per-turn signal emission from
* having to be added to each copy by hand (#563).
*/
export async function drainGates(turn, deps, continueWith) {
let gate = parseAwaitGate(turn.text);
for (let round = 0; round < MAX_AWAIT_ROUNDS && gate; round++) {
const { answer, stop } = await resolveAwaitGate(gate, round, deps);
// A stopping answer is the one answer the agent is never given (#358): it is told nothing and
// the turn it asked from is the last one. Not exhausted — the round cap had nothing to do
// with it, and reporting both would make a deliberate stop read as an agent that ran out.
if (stop) {
deps.emit({ kind: 'log', message: stopMessage(answer) });
return { turn, exhausted: false, stopped: true };
}
deps.emit({ kind: 'log', message: `Continuing with your choice: ${answer}` });
turn = await continueWith(gate.title, answer);
deps.emitTurnSignals(turn.text);
gate = parseAwaitGate(turn.text);
}
return { turn, exhausted: gate !== undefined, stopped: false };
}
/** Continue a plain driver session from a gate answer: the raw-prompt half of {@link drainGates}. */
function promptContinuation(session, deps) {
const signalOpt = deps.signal ? { signal: deps.signal } : {};
return (question, answer) => session.prompt(continuationPrompt(question, answer), signalOpt);
}
/**
* The live-chat loop (#714): deliver each of the user's messages by resuming the same
* session (full conversational context), then honor any await gate it produced. Shared by
* the direct prompt path and the build path, which both reach it once their work has settled.
*
* How it ends is the session-lifecycle rule of #1390. By default the loop only *drains*: a
* message that already arrived is processed, and once the queue is idle the session ends
* itself rather than parking on the user — merge fires at that natural end (armed + gated),
* and a follow-up reopens the conversation via `--resume` (#762), like Claude Code web.
* `stayOpen` keeps the old park-for-the-next-message lifecycle, for an agent whose own terminal
* dashboard is the only surface — it has no daemon to resume through, so ending would leave
* its composer a dead end; that agent still ends on Stop / budget cap (next -> undefined).
*
* Reports the settled `exhausted` of the *last* chat turn (#742): entering chat means the
* opening prompt's await-round cap is no longer the agent's end reason, and a phase that ends
* on Stop / close is not exhausted at all.
*/
export async function runChatPhase(session, messages, seed, deps, stayOpen = false) {
const signalOpt = deps.signal ? { signal: deps.signal } : {};
let turn = seed;
let exhausted = false;
for (;;) {
let message;
if (stayOpen) {
// Parked on the user (#785): the work is settled and nothing is running until a message
// arrives. Said out loud each time round, so a reader can tell waiting from working.
deps.emit({ kind: 'settled' });
message = await messages.next(deps.signal);
}
else {
// Nothing needs a human (#1390): no park, no `settled` limbo — an idle queue is the
// session's natural end, and the agent's `end` event follows right behind it.
message = deps.signal?.aborted ? undefined : messages.takeQueued();
}
if (message === undefined)
return { turn, exhausted, stopped: false }; // idle queue / Stop / budget cap: end the conversation.
// The message shows in the feed as the driver's own `start` event (the YOU row), so it is not
// echoed as a separate log line — that only duplicated it.
turn = await session.prompt(message.text, { ...signalOpt, resume: true });
deps.emitTurnSignals(turn.text);
const drained = await drainGates(turn, deps, promptContinuation(session, deps));
turn = drained.turn;
exhausted = drained.exhausted;
// A stopping answer ends the whole session, not just the message it came from (#358): the
// user is taking over, so parking for their next chat message would be waiting on someone
// who has already answered.
if (drained.stopped)
return { turn, exhausted, stopped: true };
}
}
/**
* Prompt the agent and honor its await gates (#337) until it stops asking: resolve each gate to
* the user's answer, re-prompt with it, and repeat up to {@link MAX_AWAIT_ROUNDS}. When a live-chat
* {@link AwaitRoundsOptions.messages} source is wired, the agent then stays open for the user's own
* messages (#714) rather than finishing.
*
* Every turn here is a turn like any other, so each one's signals are emitted. That is the
* point of sharing this: the direct prompt path and the backlog loop each had their own copy
* of these rounds, and the emission had to be added to each by hand (#563).
*/
export async function runAwaitRounds(opts) {
const { session, emit, emitTurnSignals, messages } = opts;
const deps = {
requestChoice: opts.requestChoice,
emit,
emitTurnSignals,
signal: opts.signal,
};
const signalOpt = opts.signal ? { signal: opts.signal } : {};
// Resuming a finished agent (#720): the opening message continues the seeded session, so the
// agent replies with full prior context. A fresh agent leaves `resume` unset — unchanged.
const opening = await session.prompt(opts.prompt, { ...signalOpt, ...(opts.resume ? { resume: true } : {}) });
emitTurnSignals(opening.text);
const drained = await drainGates(opening, deps, promptContinuation(session, deps));
// A stopping answer (#358) ends the exchange here rather than opening chat on it: the user
// said stop, and a composer waiting for their next message is not what stopping looks like.
if (drained.stopped)
return { text: drained.turn.text, exhausted: false, stopped: true };
// Live chat (#714): take the user's messages — draining what queued and ending on idle, or
// parked until Stop for a terminal-dashboard agent (#1390). Headless leaves it unset,
// so the agent ends here exactly as before. Once chat runs, its settled state is the agent's end
// reason, not the opening drain's (#742) — otherwise a chat closed by Stop would still be
// reported "exhausted" and log a spurious await-limit notice.
if (messages) {
const chat = await runChatPhase(session, messages, drained.turn, deps, opts.stayOpenChat === true);
return { text: chat.turn.text, exhausted: chat.exhausted, stopped: chat.stopped };
}
return { text: drained.turn.text, exhausted: drained.exhausted, stopped: false };
}
/** The recommended fallback pick when a single-select gate cannot get a real answer. */
const PROCEED = { picked: 'proceed', by: 'auto' };
/**
* Resolve with the human's pick, or fall back to `fallback` if the pick rejects or
* the agent aborts first (user stop / budget cap #322) — so a gate parked for input
* never hangs. Never rejects. Cleans up its abort listener either way. The fallback
* is the single-select `proceed` by default; a multi-select passes its default set.
*/
function raceChoiceOrAbort(pick, signal, fallback = PROCEED) {
if (!signal)
return pick.catch(() => fallback);
if (signal.aborted)
return Promise.resolve(fallback);
return new Promise(resolve => {
const onAbort = () => resolve(fallback);
signal.addEventListener('abort', onAbort, { once: true });
const done = (value) => {
signal.removeEventListener('abort', onAbort);
resolve(value);
};
pick.then(done, () => done(fallback));
});
}
/**
* The single-select gate (#304): show the options with the recommended one
* pre-selected, pause, and resolve to the *one* option id the user picked. The twin
* of {@link requestMultiSelect} for "pick one" — the agent-facing `showChoices()`
* from the built-in system (#326) prompt and the [Research] preset (#331) both build on it.
* A headless agent (no `requestChoice`), or one aborted mid-await, falls back to the
* recommended option without hanging, so a programmatic agent stays deterministic.
*/
export async function requestChoices(deps) {
const { options, requestChoice, emit } = deps;
const recommended = deps.recommended ?? options[0]?.id ?? '';
const req = {
id: deps.id,
title: deps.title,
options: options.map(o => ({ id: o.id, label: o.label, ...(o.detail ? { detail: o.detail } : {}) })),
...(recommended ? { recommended } : {}),
...(deps.file ? { file: deps.file } : {}),
};
emit({ kind: 'choice', ...req });
const validIds = new Set(options.map(o => o.id));
const fallback = { picked: recommended, by: 'auto' };
// Headless: no one to ask, so accept the recommended option.
const pick = requestChoice
? await raceChoiceOrAbort(requestChoice(req), deps.signal, fallback)
: fallback;
const picked = pickedIds(pick.picked)[0] ?? '';
const resolved = validIds.has(picked) ? picked : recommended;
emit({ kind: 'choice-resolved', id: req.id, picked: resolved, by: pick.by ?? 'user' });
return resolved;
}
/**
* The multi-select gate (#332): show a checklist with the default-checked options
* pre-selected, pause, and resolve to the *subset* of option ids the user kept
* checked. Built on the same `choice` gate + POST-back resolver as the single-select
* plan-approval gate (#304), just in checklist mode. A headless agent (no
* `requestChoice`) auto-accepts the default set without pausing, so a programmatic
* run stays deterministic. This is the primitive the [Research] preset (#331) uses
* to let the user pick which problems to deep-dive.
*/
export async function requestMultiSelect(deps) {
const { options, requestChoice, emit } = deps;
const defaults = options.filter(o => o.default).map(o => o.id);
const req = {
id: deps.id,
title: deps.title,
multi: true,
options: options.map(o => ({
id: o.id,
label: o.label,
...(o.detail ? { detail: o.detail } : {}),
...(o.default ? { default: true } : {}),
})),
};
emit({ kind: 'choice', ...req });
const validIds = new Set(options.map(o => o.id));
const asDefaults = { picked: defaults, by: 'auto' };
// Headless: no one to ask, so accept the defaults (the recommended set).
const pick = requestChoice
? await raceChoiceOrAbort(requestChoice(req), deps.signal, asDefaults)
: asDefaults;
const selected = pickedIds(pick.picked).filter(id => validIds.has(id));
emit({ kind: 'choice-resolved', id: req.id, picked: selected, by: pick.by ?? 'user' });
return selected;
}
//# sourceMappingURL=await-gate.js.map