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.
164 lines • 8.92 kB
JavaScript
import { listAgents, readLiveMetas } from '../store/index.js';
import { isAgentBranch, readAgentHandoff, agentBranchFor } from './agent-handoff.js';
import { ghPrList } from './gh.js';
import { interventionKey } from './keys.js';
import { postDiscordWebhook } from './discord-webhook.js';
// Pure identity + diff, in the leaf `keys.ts` so the dashboard shares them rather than copying.
export { interventionKey } from './keys.js';
/** How many recent finished agents are inspected per project by default. */
const HANDOFF_LIMIT = 5;
/**
* Build the cross-project interventions queue: every registered project's open PRs, plus any run
* currently paused on a choice gate (#636), newest first. Forgiving — a project with no remote
* (or an unreadable one) simply contributes nothing. Hand-opened draft PRs are excluded: they are
* not yet asking for review. A session's own draft is not (#1102), because that is how
* auto-handoff hands work back.
*
* Which projects were read whole comes back alongside the items (#1623): forgiveness here is what
* lets one unreachable project keep the queue useful, and it is also what would let that project's
* whole backlog announce itself as new the moment it came back. The queue's panels ignore this;
* the notification watcher is the caller that cannot.
*/
export async function buildInterventions(projects, deps = {}) {
const prs = deps.prs ?? ghPrList;
const liveAgents = deps.liveAgents ?? readLiveMetas;
const items = [];
const whole = [];
for (const project of projects) {
// A project is read whole only when every one of its sources answered. Each `unread()` below is
// a source that did not, and contributed nothing for a reason other than nothing being there.
let sawEverything = true;
const unread = () => {
sawEverything = false;
return [];
};
const open = await prs(project.path).catch(unread);
for (const pr of open) {
// A draft opened by hand is not asking for review, so it stays off the queue. A draft the
// framework opened for a session is the opposite (#1102): auto-handoff opens it as a draft
// precisely so it does not ping reviewers, and if the queue then dropped it too, nothing
// would tell anyone the work exists — which is the whole of #860 again.
if (pr.isDraft && !isAgentBranch(pr.headRefName))
continue;
items.push({
projectId: project.id,
projectName: project.name,
kind: 'pr',
number: pr.number,
title: pr.title,
url: pr.url,
...(pr.createdAt ? { createdAt: pr.createdAt } : {}),
});
}
// An agent paused mid-flight to ask the user is a "needs you" too (#636): a live agent that is
// still `running` and has an unresolved choice gate. An agent parks on one gate at a time, but
// a project now has several concurrent agents (#736), so each parked agent contributes its own
// item — keyed on the gate id, plus the agent id so two agents are told apart.
for (const meta of await liveAgents(project.path).catch(unread)) {
if (meta.status !== 'running' || !meta.pendingChoice)
continue;
items.push({
projectId: project.id,
projectName: project.name,
kind: 'awaiting',
title: meta.pendingChoice.title,
url: deps.dashboardUrl ?? '',
awaitId: meta.pendingChoice.id,
agentId: meta.id,
...(meta.updatedAt ? { createdAt: meta.updatedAt } : {}),
});
}
// A finished agent whose work never left the machine is a "needs you" too (#860). Until now the
// queue only knew about a PR that is *already on GitHub* and an agent parked on a gate, so an agent
// that committed real code and stopped produced neither, and nothing told anyone: the overview
// drops it (it filters on `running`) and the handoff panel is behind clicking into that agent.
//
// Surfacing only: this says there is a decision waiting, it does not take it. Since #1102 a
// session usually pushes itself, so what reaches here is the remainder — auto-handoff turned
// off for the project, or turned off for that session, or tried and failed.
for (const item of await unpushedFor(project, deps, unread).catch(unread))
items.push(item);
if (sawEverything)
whole.push(project.id);
}
items.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
// The same repo can be registered under two projects (e.g. a monorepo root + a subdir), so a
// PR would otherwise appear once per entry. Collapse by identity, keeping the first (newest-sorted).
const seen = new Set();
const unique = items.filter(item => (seen.has(interventionKey(item)) ? false : (seen.add(interventionKey(item)), true)));
return { items: unique, whole };
}
/**
* The finished agents of a project whose branch still holds unpushed, unmerged commits (#860).
*
* Only the most recent {@link InterventionsDeps.handoffLimit} finished agents are inspected: each
* costs several git reads and this runs on a poll.
*/
async function unpushedFor(project, deps, unread) {
const agents = deps.agents ?? listAgents;
const handoff = deps.handoff ??
// The default skips the `gh` PR lookup `readAgentHandoff` would otherwise do per branch: an open
// PR means the branch was pushed, so `pushed` already excludes it, and the `pr` kind above is
// what surfaces it. Paying an 8s-timeout network call per agent on every poll to learn that
// would be the most expensive part of this whole queue.
((cwd, branch) => readAgentHandoff(cwd, branch, { pr: async () => undefined }));
const finished = (await agents(project.path))
.filter(agent => agent.status !== 'running')
.sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? ''))
.slice(0, deps.handoffLimit ?? HANDOFF_LIMIT);
const items = [];
for (const agent of finished) {
const branch = agentBranchFor(agent);
const state = await handoff(project.path, branch).catch(() => {
unread();
return undefined;
});
// Every condition is a reason this is *not* waiting on anyone: the branch is gone, the session
// wrote nothing, it already landed, it is already on the remote, or there is nowhere to push.
if (!state || !state.exists || state.empty || state.merged || state.pushed || !state.hasRemote)
continue;
items.push({
projectId: project.id,
projectName: project.name,
kind: 'unpushed',
title: agent.intent?.trim() || branch,
url: deps.dashboardUrl ?? '',
agentId: agent.id,
branch,
commits: state.commits.length,
...(agent.updatedAt ? { createdAt: agent.updatedAt } : {}),
});
}
return items;
}
/**
* How one intervention reads on Discord. Beside {@link Intervention} rather than inside the
* watcher that posts it: it switches on every `kind`, so adding a kind is a change here, not in
* a transport module that has no other opinion about what an intervention is.
*
* A PR reads `#123 Title — url`; a paused agent (#636) has no number and only the dashboard url,
* so it reads `Title — awaiting your answer` with the link appended when the daemon knows it.
* Unpushed work (#860) names the branch, since that is the actionable part.
*/
export function interventionLine(item) {
if (item.kind === 'awaiting')
return `${item.title} — awaiting your answer${item.url ? ` — ${item.url}` : ''}`;
if (item.kind === 'unpushed') {
const count = item.commits === 1 ? '1 commit' : `${item.commits ?? 0} commits`;
return `${item.title} — ${count} on ${item.branch ?? ''}, never pushed${item.url ? ` — ${item.url}` : ''}`;
}
return `#${item.number} ${item.title} — ${item.url}`;
}
/**
* Post the given interventions to a Discord webhook as one message, resolving whether Discord
* accepted it (#940). `fetch` is injectable for tests.
*/
export async function postInterventionsDiscord(webhook, items, fetchImpl = fetch) {
if (items.length === 0)
return true;
const content = items.length === 1
? `🔔 Needs you (${items[0].projectName}): ${interventionLine(items[0])}`
: `🔔 ${items.length} items need you:\n${items.map(i => `• ${interventionLine(i)}`).join('\n')}`;
return postDiscordWebhook(webhook, content, fetchImpl);
}
//# sourceMappingURL=interventions.js.map