/** * Workflows Extension (Davis-style) * * Registers: * - Tool: `workflow` — Execute a workflow script inline with background support. * - Command: `/workflows` — Dashboard TUI overlay for managing workflow runs. * * Script format: * export const meta = { name: "...", description: "...", phases: [...] }; * phase("title"); * await agent("prompt", { label, phase, schema, model, provider, effort }); * await parallel([() => agent(...), ...], { concurrency }); * * The script executes in a sandbox child process (Node --permission + vm). * Agent calls are executed in-process by the parent via AgentSession. * * Features: * - Max 32 agent calls, max 4 concurrent * - Model defaults from ctx.model, overridable per agent() * - Thinking level from pi.getThinkingLevel(), overridable via effort * - Background runs: same runId returned, registered before return * - Single followUp message via pi.sendUserMessage on completion * - Graceful shutdown with bounded wait and Windows-adapted process kill * - Artifacts stored at getAgentDir()/workflows// * - /workflows dashboard overlay with auto-refresh */ import { randomUUID } from "node:crypto"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { type WorkflowProgress, type WorkflowToolParams, type WorkflowToolResult, type StepProgress, type UsageStats, } from "./shared/types.ts"; import { extractMeta } from "./shared/workflow-parser.ts"; import { validateLimits } from "./shared/limits.ts"; import { runWorkflow } from "./shared/workflow-runner.ts"; import { saveProgress, loadProgress, loadOutput, listRunsWithStatus, deleteRun, } from "./shared/artifacts.ts"; import { shutdownAll, activeRunCount, getActiveRuns, } from "./shared/cleanup.ts"; import { createDashboard } from "./shared/dashboard.ts"; // ── Helpers ───────────────────────────────────────────────────────────────────── function asContent(s: string): { type: "text"; text: string }[] { return [{ type: "text", text: s }]; } function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; return `${Math.floor(ms / 60000)}m${Math.round((ms % 60000) / 1000)}s`; } function formatTokens(n: number): string { if (n < 1000) return String(n); if (n < 10000) return `${(n / 1000).toFixed(1)}k`; return `${Math.round(n / 1000)}k`; } function formatUsageStats(usage: UsageStats): string { const parts: string[] = []; if (usage.input) parts.push(`↑${formatTokens(usage.input)}`); if (usage.output) parts.push(`↓${formatTokens(usage.output)}`); if (usage.cost) parts.push(`$${usage.cost.toFixed(3)}`); return parts.join(" "); } function formatStepLine(step: StepProgress): string { const icon = step.status === "completed" ? "✓" : step.status === "failed" ? "✗" : step.status === "running" ? "⟳" : step.status === "cancelled" ? "✕" : "○"; const duration = step.startedAt ? formatDuration((step.finishedAt ?? Date.now()) - step.startedAt) : "--"; const usage = formatUsageStats(step.usage); const label = step.label || step.stepId; const line = `${icon} ${label} (${step.phase || "default"}) ${duration}`; return usage ? `${line} · ${usage}` : line; } function formatProgressSummary(p: WorkflowProgress): string { const header = `Workflow: ${p.workflowName}\nRun ID: ${p.runId}\nStatus: ${p.status}\nStarted: ${new Date(p.startedAt).toLocaleTimeString()}\n`; const stepLines = p.steps.map((s) => ` ${formatStepLine(s)}`).join("\n"); const calls = `\nAgent calls: ${p.totalAgentCalls}/${p.maxAgentCalls}`; const error = p.error ? `\nError: ${p.error}` : ""; return header + stepLines + calls + error; } // ── Extension ────────────────────────────────────────────────────────────────── export default function workflowsExtension(pi: ExtensionAPI) { // ── Widget ───────────────────────────────────────────────────────────── function installWidget(ctx: any): void { if (!ctx.hasUI) return; ctx.ui.setWidget( "workflows", (_tui: any, theme: any) => ({ render(width: number) { const count = activeRunCount(); if (count === 0) return []; return [ `${theme.fg("accent", "⚙ workflow")} ${count} running · /workflows`, ]; }, invalidate() {}, }), { placement: "aboveEditor" }, ); } // ── Tool: workflow ───────────────────────────────────────────────────── pi.registerTool({ name: "workflow", label: "Workflow", description: "Execute a workflow script. Define multi-step agent pipelines using " + "inline JS: export const meta = {...}; phase(title); await agent(prompt, options); " + "await parallel([() => agent(...), ...], { concurrency }). " + "Use /workflows to open the dashboard.", promptSnippet: "workflow(script, args?, background?) → execute a workflow", promptGuidelines: [ "Use workflow for complex multi-step tasks that require coordination between agents.", "Script format: export const meta = { name, description, phases };", "Use phase(title) to group steps; await agent(prompt, { label, phase, schema, model, provider, effort }) to call agents.", "Use parallel([...], { concurrency }) for concurrent agent calls (max 4).", "Workflows support up to 32 agent calls with max 4 concurrent.", "Set background: true to run the workflow asynchronously.", "Use /workflows to view, inspect, and cancel workflow runs.", ], parameters: Type.Object({ script: Type.String({ description: "Workflow script content (JS). Must export const meta = { name, ... }.", }), args: Type.Optional( Type.String({ description: "JSON object exposed to the workflow script as global `args`.", }), ), background: Type.Optional( Type.Boolean({ description: "Run the workflow in background. Returns immediately with a run ID for follow-up.", }), ), }), async execute( _toolCallId: string, params: WorkflowToolParams, signal: AbortSignal | undefined, onUpdate: any, ctx: ExtensionContext, ): Promise { // Validate limits const limits = validateLimits(params.script); if (!limits.ok) { return { content: asContent(`Workflow validation error: ${limits.reason}`), details: { runId: "", workflowName: "", status: "failed" as const, steps: [], totalAgentCalls: 0, }, }; } let workflowArgs: Record = {}; if (params.args) { try { if (Buffer.byteLength(params.args, "utf8") > 16 * 1024) { throw new Error("args exceed 16 KiB"); } const parsed = JSON.parse(params.args); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("args must be a JSON object"); } workflowArgs = parsed as Record; } catch (error) { return { content: asContent(`Workflow args error: ${error instanceof Error ? error.message : String(error)}`), details: { runId: "", workflowName: "", status: "failed" as const, steps: [], totalAgentCalls: 0, }, }; } } // Extract meta const metaResult = extractMeta(params.script); const workflowName = metaResult.success ? metaResult.meta!.name : "unnamed"; // Resolve model defaults from context const defaultModel = ctx.model?.id ?? undefined; const defaultProvider = ctx.model?.provider ?? undefined; // Resolve thinking level from pi config let thinking: any = "medium"; try { if (typeof (pi as any).getThinkingLevel === "function") { thinking = (pi as any).getThinkingLevel(); } } catch { // Use default } const background = params.background === true; if (background) { // ── Background execution ────────────────────────────────────────── const runId = randomUUID(); // Initialize progress const progress: WorkflowProgress = { runId, workflowName, status: "running", startedAt: Date.now(), finishedAt: null, phases: metaResult.success ? (metaResult.meta!.phases ?? []) : [], steps: [], totalAgentCalls: 0, maxAgentCalls: 32, error: null, output: null, }; saveProgress(runId, progress); // Fire and forget (background) — runWorkflow handles exactly one registration (async () => { try { const result = await runWorkflow({ script: params.script, args: workflowArgs, cwd: ctx.cwd, signal: undefined, modelRegistry: ctx.modelRegistry, defaultModel, defaultProvider, thinking, background: true, pi, runId, // caller-provided: reuse, don't create a second ID onProgress: (p) => { saveProgress(runId, p); }, }); // Save final (runWorkflow already saves, but overwrite to ensure consistency) saveProgress(runId, { ...progress, ...result, workflowName, status: result.status, finishedAt: Date.now(), totalAgentCalls: result.totalAgentCalls, steps: result.steps, output: result.output, error: result.error ?? null, }); // Send follow-up message — exactly once, with correct signature try { if (typeof (pi as any).sendUserMessage === "function") { const summary = result.status === "completed" ? `Workflow "${workflowName}" completed successfully (${result.steps.length} steps, ${result.totalAgentCalls} agent calls).\nUse /workflows inspect ${runId.slice(0, 8)} for details.` : `Workflow "${workflowName}" ${result.status}${result.error ? `: ${result.error}` : ""}.\nUse /workflows inspect ${runId.slice(0, 8)} for details.`; (pi as any).sendUserMessage(summary, { deliverAs: "followUp" }); } } catch { // Best effort } } catch (err: any) { saveProgress(runId, { ...progress, status: "failed", finishedAt: Date.now(), error: err.message || "Unknown error", }); try { if (typeof (pi as any).sendUserMessage === "function") { const msg = `Workflow "${workflowName}" failed: ${err.message || "Unknown error"}`; (pi as any).sendUserMessage(msg, { deliverAs: "followUp" }); } } catch { // Best effort } } })(); return { content: asContent( `Workflow "${workflowName}" started in background.\nRun ID: ${runId}\nUse /workflows to monitor progress.`, ), details: { runId, workflowName, status: "running" as const, steps: [], totalAgentCalls: 0, }, }; } // ── Foreground execution ──────────────────────────────────────────── let lastProgress: WorkflowProgress | undefined; const result = await runWorkflow({ script: params.script, args: workflowArgs, cwd: ctx.cwd, signal, modelRegistry: ctx.modelRegistry, defaultModel, defaultProvider, thinking, background: false, onProgress: (progress) => { lastProgress = progress; // Send partial updates to the tool caller if (onUpdate) { onUpdate({ content: asContent(formatProgressSummary(progress)), details: { runId: progress.runId, workflowName: progress.workflowName, status: progress.status, steps: progress.steps, totalAgentCalls: progress.totalAgentCalls, }, }); } }, }); const summary = formatProgressSummary({ runId: result.runId, workflowName, status: result.status, startedAt: Date.now(), finishedAt: Date.now(), phases: [], steps: result.steps, totalAgentCalls: result.totalAgentCalls, maxAgentCalls: 32, error: result.error ?? null, output: result.output, }); return { content: asContent(result.output || summary), details: { runId: result.runId, workflowName, status: result.status, steps: result.steps, totalAgentCalls: result.totalAgentCalls, output: result.output, }, }; }, }); // ── Command: /workflows ──────────────────────────────────────────────── pi.registerCommand("workflows", { description: "Workflow dashboard: view, inspect, and cancel workflow runs. " + "/workflows [list|inspect |cancel |delete |run