/** * What every command in this extension stands on. * * `/run`, `/step`, `/swarm` and `/interview` all launch the same * shape of work: a roster, a few checks that must pass before anything is * spawned, a live view for as long as the work runs, and a `finally` that takes * it down and writes what it cost. That shape is written here once. What a * command does inside it stays in its own file. */ import { loadAgents, loadFlowCatalogue, type Agent, type FlowCatalogue } from "../src/index.ts"; import type { CommandDeps, Deps } from "./deps.ts"; import { fitted, type Level } from "./notice.ts"; import type { CommandCtx, RunUi } from "./pi.ts"; import { liveRun, STATUS, type LiveRun, type LiveRunOptions } from "./ui/index.ts"; /** * The roster every command runs with. * * `scope: "both"` because a user typing a command in a repository *is* the * explicit request the project-agents rule asks for; `builtin: true` because the * agents shipped with this extension are always available, at the lowest * priority - one of the user's own, or the repository's, replaces any of them by * name. Written once: three call sites drifting on either flag is how two * commands end up disagreeing about who exists. */ export function loadRoster(ctx: CommandCtx, deps: Pick = { loadAgents }): Agent[] { return deps.loadAgents({ cwd: ctx.cwd, scope: "both", builtin: true }); } /** The flows every command runs with, and the agents they name, on the same terms as the roster. */ export function loadFlows(ctx: CommandCtx, deps: Pick = { loadFlowCatalogue }): FlowCatalogue { return deps.loadFlowCatalogue({ cwd: ctx.cwd, scope: "both", builtin: true }); } /** * Notifies and returns `undefined` - the shape every refusal in these * commands has. The message is fitted to the terminal first, so pi never cuts * a path it names in two. */ export function refuse(ctx: CommandCtx, message: string, level: Level): undefined { ctx.ui.notify(fitted(message, ctx.cwd, level), level); return undefined; } /** * The checks that must pass before anything is spawned. * * A typo costs a second here rather than a step of real work. Whatever `check` * throws is an explanation meant for the user: it is shown, and `undefined` * comes back - the same shape as every other refusal. */ export async function checked(ctx: CommandCtx, check: () => T | Promise): Promise { try { return await check(); } catch (cause) { return refuse(ctx, cause instanceof Error ? cause.message : String(cause), "error"); } } /** What is watched while it works, and where the trace of it lands. */ export type Watched = { /** The footer while the work runs: `running explore…`. Absent leaves the footer alone. */ status?: string; /** Where `usage.json` is written when it is over. Absent writes none. */ dir: string | undefined; /** * What this caller varies about the view: the tool streams a progress line * and knows the parent session, a test injects a spawn and a reporter. */ live?: Pick; /** The work, handed the live run: its `signal`, its `spawn`, its `onEvent`. */ work: (live: LiveRun) => Promise; }; /** Who stands on the floor: a command with pi's context, or the tool with what pi handed it. */ export type Watcher = { ui?: RunUi; signal?: AbortSignal }; /** * Runs the work under the dots, and takes them down whatever happens. * * One painter, one `finally`, one clock: the live view goes up, the footer says * what is running, and on the way out - thrown or not - the widget and the * footer are cleared and the run's `usage.json` written with the time the view * measured. A thrown `work` still throws, after the clean-up. */ export async function watched(ctx: Watcher, deps: Pick, at: Watched): Promise { const live = liveRun(ctx.ui, { ...at.live, dir: at.dir, tickMs: deps.tickMs, signal: ctx.signal }); if (at.status) ctx.ui?.setStatus?.(STATUS, at.status); try { return await at.work(live); } finally { live.stop(); } }