/** * Wire the decoupled workflow RunnerDeps (runner.ts) to a live Pi host. * * This is the ONE place pi-workflow touches the Pi runtime. Each seam maps a runner * capability onto the Pi ExtensionCommandContext: * - runAgent → a headless createAgentSession turn (no sub-extensions, safe-tools * ceiling), draining assistant text and best-effort JSON output * - askGate → ctx.ui.select (the host's approval dialog) * - runScript → a child_process the runner ONLY reaches after its fail-closed * attended+approved check * - attended → ctx.hasUI (a dialog-capable UI is driving) * - loadSubWorkflow → the path-confined store loader * * The Pi SDK is a PEER dependency loaded dynamically inside runAgent, so importing this * module (e.g. from a unit test) never requires the Pi runtime to be present. */ import { spawn } from "node:child_process"; import type { RunnerDeps, AgentRunSpec, AgentRunResult, ScriptRunResult, RunnerEvent } from "./runner.ts"; import type { Step } from "./schema.ts"; import { resolveSubWorkflow, workflowsDir } from "./store.ts"; // Read-only safe default ceiling for an agent step that omits `tools:`. Deliberately // excludes bash/edit/write — a workflow that needs those must name them explicitly, and // a `script` step (not a tool) is the gated path for real command execution. export const DEFAULT_AGENT_TOOLS = ["read", "grep", "find", "ls"]; // The subset of the Pi ExtensionCommandContext we depend on — structural so we don't pin // to Pi's exact internal types (verified against ExtensionCommandContext in 0.80). export interface PiRunnerCtx { cwd: string; hasUI: boolean; agentDir?: string; model?: { provider?: string; id?: string } | undefined; ui: { select(title: string, options: string[], opts?: unknown): Promise; notify(message: string, type?: "info" | "warning" | "error"): void; setStatus?(key: string, text: string | undefined): void; }; } export interface PiRunnerOptions { // Default "provider:model" for agent steps with no `model:` and no workflow default. // Falls back to the context's current model when omitted. defaultModel?: string; // The workflows directory sub-workflow refs resolve against (confined). Defaults to the // store's workflowsDir() (~/.pi/workflows or $PI_WORKFLOWS_DIR). workflowsDir?: string; // Fan-out concurrency cap (parallel groups, for_each ceiling). Default 4. concurrency?: number; // Live progress sink. Default surfaces step starts via ctx.ui (status line + a notice). onEvent?(ev: RunnerEvent): void; // Suppress the default step-start notices (still forwards to a custom onEvent). quiet?: boolean; } // Split a "provider:model" or "provider/model" spec on its first separator (model ids // themselves contain "/", so we key off the first delimiter). Mirrors the daemon. function parseSpec(spec: string): { provider: string; modelId: string } { const i = spec.indexOf(":"); const j = spec.indexOf("/"); const sep = i === -1 ? j : j === -1 ? i : Math.min(i, j); if (sep <= 0) return { provider: spec, modelId: "" }; return { provider: spec.slice(0, sep), modelId: spec.slice(sep + 1) }; } // Best-effort parse of agent stdout as a JSON object → structured `output` (so a later // step can route on `{{ step.output.field }}`). Non-object output leaves output empty. function parseJsonOutput(text: string): Record { try { const p = JSON.parse(text.trim()); if (p && typeof p === "object" && !Array.isArray(p)) return p as Record; } catch { /* non-JSON stdout → raw text only */ } return {}; } // Drive one headless agent turn. Builds a throwaway in-memory session with NO // sub-extensions (so running a workflow's agent step never recursively loads this // extension) and the step's tool ceiling. Reuses the default agentDir, so the user's // stored provider credentials resolve exactly as in a normal Pi session. async function runAgent(spec: AgentRunSpec, ctx: PiRunnerCtx, defaultModelSpec: string | undefined): Promise { // Computed specifier: the Pi SDK is an OPTIONAL peer dep (always present in a real Pi // host, absent in this standalone package's own typecheck/tests). A non-literal specifier // keeps TS from trying to resolve it here, and the dynamic load fails cleanly (caught // below) in the rare case it's genuinely missing. const piModule = "@earendil-works/pi-coding-agent"; const pi: any = await import(piModule); const { createAgentSessionServices, createAgentSessionFromServices, SessionManager } = pi; let out = ""; let status: "ok" | "error" = "ok"; let error: string | undefined; try { const services = await createAgentSessionServices({ cwd: spec.cwd, ...(ctx.agentDir ? { agentDir: ctx.agentDir } : {}), // No sub-extensions inside a workflow agent step — keeps the turn lean and prevents // this very extension from re-loading recursively. resourceLoaderOptions: { extensionFactories: [] }, }); const modelSpec = (spec.model && spec.model.trim()) || defaultModelSpec; let model: unknown; if (modelSpec) { const { provider, modelId } = parseSpec(modelSpec); model = services.modelRegistry.find(provider, modelId); } // Fall back to the host's current model when no usable override was resolved. if (!model && ctx.model?.provider && ctx.model?.id) { model = services.modelRegistry.find(ctx.model.provider, ctx.model.id); } if (!model) { return { text: "", output: {}, status: "error", error: `model "${modelSpec ?? "(default)"}" not found` }; } const tools = spec.tools && spec.tools.length ? spec.tools : DEFAULT_AGENT_TOOLS; const { session } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(spec.cwd), model, tools, }); session.subscribe((ev: any) => { if (ev?.type === "message_update") { const a = ev.assistantMessageEvent; if (a?.type === "text_delta") out += a.delta ?? ""; } else if (ev?.type === "agent_end" && ev.error) { status = "error"; error = typeof ev.error === "string" ? ev.error : (ev.error?.message ?? String(ev.error)); } }); await session.prompt(spec.prompt); } catch (err) { status = "error"; error = err instanceof Error ? err.message : String(err); } return { text: out, output: parseJsonOutput(out), status, error }; } // Execute a workflow `script` step as a child process. The runner ONLY calls this after // its fail-closed posture check (attended + approved), so reaching here means the user // authorized this exact command. Args are passed as argv (no shell); stdout is parsed as a // JSON object into `output`; the process is hard-killed at its timeout. function runScript(step: Extract, cwd: string): Promise { return new Promise((resolve) => { let out = ""; let err = ""; let done = false; const finish = (r: ScriptRunResult) => { if (done) return; done = true; resolve(r); }; let child: ReturnType; try { child = spawn(step.command, step.args, { cwd, env: { ...process.env, ...(step.env ?? {}) }, stdio: ["ignore", "pipe", "pipe"], }); } catch (e) { return finish({ output: {}, status: "error", exitCode: -1, error: (e as Error).message }); } const timer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* already gone */ } finish({ output: {}, status: "error", exitCode: -1, error: `script timed out after ${step.timeout ?? 120}s` }); }, (step.timeout ?? 120) * 1000); child.stdout?.on("data", (d) => { out += String(d); }); child.stderr?.on("data", (d) => { err += String(d); }); child.on("error", (e) => { clearTimeout(timer); finish({ output: {}, status: "error", exitCode: -1, error: e.message }); }); child.on("close", (code) => { clearTimeout(timer); finish({ output: parseJsonOutput(out), status: code === 0 ? "ok" : "error", exitCode: code ?? -1, error: code === 0 ? undefined : (err.trim().slice(0, 500) || `exited ${code}`) }); }); }); } /** * Build the RunnerDeps for a Pi host. Pass the extension's command context and options; * the returned deps are handed straight to `runWorkflow(wf, input, deps)`. */ export function makePiRunnerDeps(ctx: PiRunnerCtx, opts: PiRunnerOptions = {}): RunnerDeps { const dir = opts.workflowsDir ?? workflowsDir(); const defaultModelSpec = opts.defaultModel ?? (ctx.model?.provider && ctx.model?.id ? `${ctx.model.provider}:${ctx.model.id}` : undefined); const defaultEvent = (ev: RunnerEvent) => { if (opts.quiet) return; if (ev.type === "step_start") { ctx.ui.setStatus?.("pi-workflow", `▶ ${ev.name}`); ctx.ui.notify(`▶ ${ev.name}`, "info"); } }; return { runAgent: (spec) => runAgent(spec, ctx, defaultModelSpec), runScript: (step, cwd) => runScript(step, cwd), // A human_gate → the host's select dialog. Options carry a description label; we map // the chosen label back to the option name. No UI → null (the runner fail-closes). askGate: async (step, promptText) => { if (!ctx.hasUI) return null; const labels = step.options.map((o) => o.description || o.name); const chosen = await ctx.ui.select(promptText, labels); if (chosen === undefined) return null; const idx = labels.indexOf(chosen); return idx >= 0 ? step.options[idx].name : chosen; }, attended: () => ctx.hasUI, deferForApproval: async (reason) => { ctx.ui.notify(reason, "warning"); }, sleep: (ms) => new Promise((r) => setTimeout(r, ms)), log: () => { /* host decides; onEvent carries the user-facing progress */ }, loadSubWorkflow: (ref) => resolveSubWorkflow(ref, dir), concurrency: opts.concurrency, onEvent: opts.onEvent ?? defaultEvent, }; }