#!/usr/bin/env node import { spawnSync } from "node:child_process"; import { readFileSync, realpathSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { WorkflowExecutor } from "./executor.js"; import type { StandaloneRunOptions, StandaloneRunState, StandaloneRunSummary, StandaloneWorkflowRuntime, } from "./standalone-runtime.js"; import type { StandaloneWorkflowServer } from "./standalone-server.js"; import { DYNAMIC_WORKFLOWS_HOME_ENV } from "./workflow-paths.js"; const BOOLEAN_FLAGS = new Set(["attach", "detach", "help", "json", "local", "ui", "auto-resume"]); const VALUE_FLAGS = new Set([ "agent-retries", "agent-timeout-ms", "args", "concurrency", "cwd", "executor", "host", "max-agents", "port", "script", "state-dir", "token-budget", ]); type FlagValue = string | boolean; export interface ParsedStandaloneCliArgs { command: string; positionals: string[]; flags: Map; } export interface StandaloneCliIo { out(text: string): void; error(text: string): void; } const DEFAULT_IO: StandaloneCliIo = { out: (text) => process.stdout.write(text), error: (text) => process.stderr.write(text), }; export function parseStandaloneCliArgs(argv: readonly string[]): ParsedStandaloneCliArgs { const input = [...argv]; if (input[0] === "--help" || input[0] === "-h") { return { command: "help", positionals: [], flags: new Map() }; } if (input[0] === "--version" || input[0] === "-v") { return { command: "version", positionals: [], flags: new Map() }; } const command = input.shift() ?? "help"; const positionals: string[] = []; const flags = new Map(); while (input.length > 0) { const argument = input.shift() as string; if (argument === "--") { positionals.push(...input); break; } if (!argument.startsWith("-") || argument === "-") { positionals.push(argument); continue; } if (argument === "-h") { flags.set("help", true); continue; } if (!argument.startsWith("--")) throw new Error(`Unknown option: ${argument}`); const equal = argument.indexOf("="); let name = argument.slice(2, equal >= 0 ? equal : undefined); const inlineValue = equal >= 0 ? argument.slice(equal + 1) : undefined; if (name.startsWith("no-") && inlineValue === undefined) { name = name.slice(3); if (!BOOLEAN_FLAGS.has(name)) throw new Error(`Unknown boolean option: --no-${name}`); flags.set(name, false); continue; } if (BOOLEAN_FLAGS.has(name)) { if (inlineValue !== undefined) { if (inlineValue !== "true" && inlineValue !== "false") { throw new Error(`--${name} expects true or false.`); } flags.set(name, inlineValue === "true"); } else { flags.set(name, true); } continue; } if (!VALUE_FLAGS.has(name)) throw new Error(`Unknown option: --${name}`); const value = inlineValue ?? input.shift(); if (value === undefined || value.startsWith("--")) { throw new Error(`--${name} requires a value.`); } flags.set(name, value); } return { command, positionals, flags }; } export async function runStandaloneCli( argv: readonly string[] = process.argv.slice(2), io: StandaloneCliIo = DEFAULT_IO, ): Promise { let parsed: ParsedStandaloneCliArgs; try { parsed = parseStandaloneCliArgs(argv); } catch (error) { io.error(`dynworkflow: ${errorMessage(error)}\n`); io.error("Run `dynworkflow help` for usage.\n"); return 64; } if (parsed.flags.get("help") === true) { io.out(helpText()); return 0; } try { applyStateDirectory(parsed); switch (parsed.command) { case "help": io.out(helpText()); return 0; case "version": io.out(`${readPackageVersion()}\n`); return 0; case "serve": case "ui": return await serveCommand(parsed, io); case "run": return await runCommand(parsed, io); case "list": case "ls": return await listCommand(parsed, io); case "show": case "status": return await showCommand(parsed, io); case "resume": case "pause": case "stop": case "delete": case "rm": return await controlCommand(parsed, io); case "doctor": return await doctorCommand(io); default: io.error(`dynworkflow: unknown command "${parsed.command}".\n`); io.error("Run `dynworkflow help` for usage.\n"); return 64; } } catch (error) { io.error(`dynworkflow: ${errorMessage(error)}\n`); return 1; } } async function runCommand(parsed: ParsedStandaloneCliArgs, io: StandaloneCliIo): Promise { const { discoverStandaloneRuntime } = await import("./standalone-client.js"); const workflowPath = requirePositional(parsed, 0, "run requires a workflow file (or - for stdin)."); const cwd = commandCwd(parsed); const request = { script: readWorkflowScript(workflowPath), args: readArgsFlag(parsed), options: runOptions(parsed), }; const json = flagBoolean(parsed, "json"); const forceLocal = flagBoolean(parsed, "local"); const discoveredClient = await discoverStandaloneRuntime(cwd); const client = forceLocal ? null : discoveredClient; if (client) { const { runId } = await client.startRun(request); if (json) { if (!flagBoolean(parsed, "attach")) { io.out(`${JSON.stringify({ runId, dashboardUrl: client.dashboardUrl() })}\n`); } } else { io.out(`Started ${runId}\nDashboard: ${client.dashboardUrl()}\n`); } if (flagBoolean(parsed, "attach")) { const settled = await client.waitForSettled(runId, { onUpdate: json ? undefined : (run) => printProgress(run, io), }); return printRunResult(settled, json, io); } return 0; } if (flagBoolean(parsed, "detach")) { throw new Error("No active runtime owns this project. Start `dynworkflow serve` before using --detach."); } const withUi = flagBoolean(parsed, "ui"); const runtime = await createRuntime(cwd, request.options, withUi ? "interactive" : "headless"); let server: StandaloneWorkflowServer | undefined; let removeProgress = () => {}; try { if (withUi) { const { StandaloneWorkflowServer } = await import("./standalone-server.js"); server = new StandaloneWorkflowServer(runtime, { port: flagNumber(parsed, "port", 0), host: flagString(parsed, "host") ?? "127.0.0.1", // `--local` deliberately ignores an existing owner. Its optional UI // must not replace that owner's discovery record. writeDescriptor: !discoveredClient, }); const listening = await server.listen(); const dashboardLine = `Dashboard: ${listening.url}\n`; if (json) io.error(dashboardLine); else io.out(dashboardLine); } const started = runtime.start(request); if (!json) io.out(`Started ${started.runId}\n`); removeProgress = listenForProgress(runtime, started.runId, io, json); await started.promise.catch(() => undefined); const settled = runtime.getRun(started.runId); if (!settled) throw new Error(`Workflow run disappeared: ${started.runId}`); const exitCode = printRunResult(settled, json, io); if (server) { const message = "Dashboard remains available; press Ctrl-C to stop the runtime.\n"; if (json) io.error(message); else io.out(message); await waitForShutdownSignal(); } return exitCode; } finally { removeProgress(); if (server) await server.close(); else runtime.close(); } } async function serveCommand(parsed: ParsedStandaloneCliArgs, io: StandaloneCliIo): Promise { const [{ discoverStandaloneRuntime }, { StandaloneWorkflowServer }] = await Promise.all([ import("./standalone-client.js"), import("./standalone-server.js"), ]); const cwd = commandCwd(parsed); const existing = await discoverStandaloneRuntime(cwd); if (existing) { io.out(`A workflow runtime is already active for ${cwd}.\nDashboard: ${existing.dashboardUrl()}\n`); return 0; } const options = runOptions(parsed); const runtime = await createRuntime(cwd, options, "interactive"); const server = new StandaloneWorkflowServer(runtime, { host: flagString(parsed, "host") ?? "127.0.0.1", port: flagNumber(parsed, "port", 4317), }); try { const listening = await server.listen(); io.out(`Workflow runtime ready for ${cwd}\nDashboard: ${listening.url}\n`); const workflowPath = parsed.positionals[0]; if (workflowPath) { const started = runtime.start({ script: readWorkflowScript(workflowPath), args: readArgsFlag(parsed), options, }); io.out(`Started ${started.runId}\n`); } io.out("Press Ctrl-C to stop; active workflows will be paused and remain resumable.\n"); await waitForShutdownSignal(); return 0; } finally { await server.close(); } } async function listCommand(parsed: ParsedStandaloneCliArgs, io: StandaloneCliIo): Promise { const { discoverStandaloneRuntime } = await import("./standalone-client.js"); const cwd = commandCwd(parsed); const client = await discoverStandaloneRuntime(cwd); const json = flagBoolean(parsed, "json"); if (client) { const state = await client.overview(); printRunList(state.runs, json, io); return 0; } const runtime = await createRuntime(cwd, {}); try { printRunList(runtime.overview().runs, json, io); return 0; } finally { runtime.close(); } } async function showCommand(parsed: ParsedStandaloneCliArgs, io: StandaloneCliIo): Promise { const { discoverStandaloneRuntime } = await import("./standalone-client.js"); const runId = requirePositional(parsed, 0, `${parsed.command} requires a run ID.`); const cwd = commandCwd(parsed); const client = await discoverStandaloneRuntime(cwd); let run: StandaloneRunState | null; if (client) { run = await client.getRun(runId); } else { const runtime = await createRuntime(cwd, {}); try { run = runtime.getRun(runId); } finally { runtime.close(); } } if (!run) throw new Error(`Unknown workflow run: ${runId}`); if (flagBoolean(parsed, "json")) { io.out(`${JSON.stringify(run, null, 2)}\n`); } else { io.out(formatRunDetails(run)); } return 0; } async function controlCommand(parsed: ParsedStandaloneCliArgs, io: StandaloneCliIo): Promise { const { discoverStandaloneRuntime } = await import("./standalone-client.js"); const runId = requirePositional(parsed, 0, `${parsed.command} requires a run ID.`); const cwd = commandCwd(parsed); const client = await discoverStandaloneRuntime(cwd); const action = parsed.command === "rm" ? "delete" : parsed.command; if (client) { let ok: boolean; if (action === "pause") ok = await client.pause(runId); else if (action === "stop") ok = await client.stop(runId); else if (action === "delete") ok = await client.delete(runId); else { ok = await client.resume(runId, { script: optionalScriptFlag(parsed), args: readArgsFlag(parsed), }); } if (!ok) throw new Error(`Could not ${action} workflow run ${runId}.`); const attach = action === "resume" && flagBoolean(parsed, "attach"); if (!flagBoolean(parsed, "json")) { io.out(`${action} accepted for ${runId}\n`); } else if (!attach) { printActionResult(action, runId, io); } if (attach) { const settled = await client.waitForSettled(runId, { onUpdate: flagBoolean(parsed, "json") ? undefined : (run) => printProgress(run, io), }); return printRunResult(settled, flagBoolean(parsed, "json"), io); } return 0; } const runtime = await createRuntime(cwd, {}); try { if (action === "resume") { const ok = await runtime.resume(runId, { script: optionalScriptFlag(parsed), args: readArgsFlag(parsed), }); if (!ok) throw new Error(`Workflow run ${runId} is not resumable.`); if (!flagBoolean(parsed, "json")) io.out(`Resumed ${runId}\n`); const settled = await runtime.waitForSettled(runId); return printRunResult(settled, flagBoolean(parsed, "json"), io); } const ok = action === "pause" ? runtime.pause(runId) : action === "stop" ? runtime.stop(runId) : runtime.delete(runId); if (!ok) { throw new Error( `Could not ${action} ${runId}. If another process owns it, send the command through its active runtime.`, ); } if (flagBoolean(parsed, "json")) printActionResult(action, runId, io); else io.out(`${action} accepted for ${runId}\n`); return 0; } finally { runtime.close(); } } async function createRuntime( cwd: string, options: StandaloneRunOptions, checkpointMode: "interactive" | "headless" = "headless", ): Promise { const { StandaloneWorkflowRuntime } = await import("./standalone-runtime.js"); return new StandaloneWorkflowRuntime({ cwd, checkpointMode, defaultExecutor: options.defaultExecutor, concurrency: options.concurrency, defaultAgentRetries: options.agentRetries, defaultAgentTimeoutMs: options.agentTimeoutMs, defaultTokenBudget: options.tokenBudget, }); } function printActionResult(action: string, runId: string, io: StandaloneCliIo): void { io.out(`${JSON.stringify({ ok: true, action, runId })}\n`); } function runOptions(parsed: ParsedStandaloneCliArgs): StandaloneRunOptions { const executor = flagString(parsed, "executor"); if (executor && !isWorkflowExecutor(executor)) { throw new Error(`--executor must be pi, codex, or claude-code; got "${executor}".`); } const defaultExecutor = executor as WorkflowExecutor | undefined; const timeout = flagString(parsed, "agent-timeout-ms"); return { defaultExecutor, maxAgents: optionalNumber(parsed, "max-agents"), concurrency: optionalNumber(parsed, "concurrency"), agentRetries: optionalNumber(parsed, "agent-retries"), agentTimeoutMs: timeout === "none" || timeout === "null" ? null : optionalNumber(parsed, "agent-timeout-ms"), tokenBudget: optionalNumber(parsed, "token-budget"), autoResume: parsed.flags.has("auto-resume") ? flagBoolean(parsed, "auto-resume") : undefined, }; } function readWorkflowScript(path: string): string { if (path === "-") return readFileSync(0, "utf8"); return readFileSync(resolve(process.cwd(), path), "utf8"); } function optionalScriptFlag(parsed: ParsedStandaloneCliArgs): string | undefined { const path = flagString(parsed, "script"); return path ? readWorkflowScript(path) : undefined; } function readArgsFlag(parsed: ParsedStandaloneCliArgs): unknown { const raw = flagString(parsed, "args"); if (raw === undefined) return undefined; const text = raw.startsWith("@") ? readFileSync(resolve(process.cwd(), raw.slice(1)), "utf8") : raw; try { return JSON.parse(text); } catch (error) { throw new Error(`--args must be JSON or @path: ${errorMessage(error)}`); } } function listenForProgress( runtime: StandaloneWorkflowRuntime, runId: string, io: StandaloneCliIo, json: boolean, ): () => void { if (json) return () => {}; let previous = ""; const listener = (event: { runId?: string }) => { if (event.runId !== runId) return; const run = runtime.getRun(runId); if (!run) return; const line = progressLine(run); if (line === previous) return; previous = line; io.error(`${line}\n`); }; runtime.on("event", listener); return () => runtime.off("event", listener); } function printProgress(run: StandaloneRunState, io: StandaloneCliIo): void { io.error(`${progressLine(run)}\n`); } function progressLine(run: StandaloneRunState): string { const done = run.agents.filter((agent) => agent.status === "done").length; const running = run.agents.filter((agent) => agent.status === "running").length; const errors = run.agents.filter((agent) => agent.status === "error").length; return `[${run.status}] ${run.currentPhase ?? run.workflowName} · ${done} done · ${running} running · ${errors} error`; } function printRunResult(run: StandaloneRunState, json: boolean, io: StandaloneCliIo): number { if (json) { io.out(`${JSON.stringify(run, null, 2)}\n`); } else { io.out(formatRunDetails(run)); if (run.result !== undefined) { io.out("\nResult\n"); io.out(`${formatValue(run.result)}\n`); } } return run.status === "completed" ? 0 : run.status === "paused" ? 2 : 1; } function printRunList( runs: Array, json: boolean, io: StandaloneCliIo, ): void { if (json) { io.out(`${JSON.stringify(runs, null, 2)}\n`); return; } if (runs.length === 0) { io.out("No workflow runs for this project.\n"); return; } io.out("RUN ID\tSTATUS\tPHASE\tAGENTS\tWORKFLOW\n"); for (const run of runs) { const count = "agents" in run ? run.agents.length : run.agentCount; const done = "agents" in run ? run.agents.filter((agent) => agent.status === "done").length : run.completedAgentCount; io.out(`${run.runId}\t${run.status}\t${run.currentPhase ?? "-"}\t${done}/${count}\t${run.workflowName}\n`); } } function formatRunDetails(run: StandaloneRunState): string { const usage = run.tokenUsage?.total ? `\nUsage: ${run.tokenUsage.total.toLocaleString()} tokens` : ""; const error = run.error?.message ? `\nError: ${run.error.message}` : ""; const lines = [ `${run.workflowName} (${run.runId})`, `Status: ${run.status}`, `Phase: ${run.currentPhase ?? "-"}`, `Agents: ${run.agents.length}`, `Started: ${run.startedAt}${usage}${error}`, ]; if (run.phases.length > 0) { lines.push("", "Phases"); for (const phase of run.phases) { lines.push(` ${phase === run.currentPhase ? "→" : "·"} ${phase}`); } } if (run.agents.length > 0) { lines.push("", "Agent input / output"); for (const agent of run.agents) { const route = [agent.executor, agent.model].filter(Boolean).join(" · "); lines.push("", ` [${agent.status}] ${agent.label}${route ? ` · ${route}` : ""}`); lines.push(" Input:"); lines.push(indentBlock(agent.prompt, " ")); if (agent.error) { lines.push(" Error:"); lines.push(indentBlock(agent.error, " ")); } else if (agent.result !== undefined) { lines.push(" Output:"); lines.push(indentBlock(formatValue(agent.result), " ")); } else if (agent.resultPreview) { lines.push(" Output preview:"); lines.push(indentBlock(agent.resultPreview, " ")); } if (agent.history?.length) { lines.push(" History:"); lines.push(indentBlock(formatValue(agent.history), " ")); } } } lines.push(""); return lines.join("\n"); } async function doctorCommand(io: StandaloneCliIo): Promise { let missing = 0; io.out(`dynworkflow ${readPackageVersion()}\n`); io.out(`Node ${process.version}\n`); try { await import("@earendil-works/pi-coding-agent"); io.out("pi: SDK available\n"); } catch { missing++; io.out("pi: SDK unavailable (optional; install Pi to use --executor pi)\n"); } for (const command of ["codex", "claude"]) { const result = spawnSync(command, ["--version"], { encoding: "utf8", shell: false }); if (result.error || result.status !== 0) { missing++; io.out(`${command}: unavailable\n`); continue; } const version = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim().split("\n").at(-1); io.out(`${command}: ${version || "available"}\n`); } return missing === 3 ? 1 : 0; } function commandCwd(parsed: ParsedStandaloneCliArgs): string { return resolve(flagString(parsed, "cwd") ?? process.cwd()); } function applyStateDirectory(parsed: ParsedStandaloneCliArgs): void { const stateDir = flagString(parsed, "state-dir"); if (stateDir) process.env[DYNAMIC_WORKFLOWS_HOME_ENV] = resolve(stateDir); } function flagString(parsed: ParsedStandaloneCliArgs, name: string): string | undefined { const value = parsed.flags.get(name); if (value === undefined) return undefined; if (typeof value !== "string") throw new Error(`--${name} requires a value.`); return value; } function flagBoolean(parsed: ParsedStandaloneCliArgs, name: string): boolean { return parsed.flags.get(name) === true; } function optionalNumber(parsed: ParsedStandaloneCliArgs, name: string): number | undefined { const raw = flagString(parsed, name); if (raw === undefined) return undefined; const value = Number(raw); if (!Number.isFinite(value) || value < 0) throw new Error(`--${name} must be a non-negative number.`); return value; } function flagNumber(parsed: ParsedStandaloneCliArgs, name: string, fallback: number): number { return optionalNumber(parsed, name) ?? fallback; } function requirePositional(parsed: ParsedStandaloneCliArgs, index: number, message: string): string { const value = parsed.positionals[index]; if (!value) throw new Error(message); return value; } function isWorkflowExecutor(value: string): value is WorkflowExecutor { return value === "pi" || value === "codex" || value === "claude-code"; } function formatValue(value: unknown): string { if (typeof value === "string") return value; try { return JSON.stringify(value, null, 2); } catch { return String(value); } } function indentBlock(value: string, prefix: string): string { return value .split("\n") .map((line) => `${prefix}${line}`) .join("\n"); } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function readPackageVersion(): string { try { const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string; }; return packageJson.version ?? "unknown"; } catch { return "unknown"; } } function waitForShutdownSignal(): Promise { return new Promise((resolveSignal) => { const finish = () => { process.off("SIGINT", finish); process.off("SIGTERM", finish); resolveSignal(); }; process.once("SIGINT", finish); process.once("SIGTERM", finish); }); } function helpText(): string { return `dynworkflow — portable, resumable multi-agent workflows Usage: dynworkflow serve [workflow.js] [options] Start the runtime and local dashboard dynworkflow run [options] Run a workflow (uses an active runtime when found) dynworkflow list [--json] List durable runs for this project dynworkflow show [--json] Show phases, agents, input, output, and usage dynworkflow resume [options] Resume a paused/failed run dynworkflow pause|stop|delete Control a run dynworkflow doctor Check Pi, Codex, and Claude Code executors Common options: --cwd Project working directory (default: current directory) --state-dir Durable state root (or set DYNAMIC_WORKFLOWS_HOME) --executor pi | codex | claude-code --args Workflow args --concurrency Maximum concurrent agents --max-agents Maximum calls in this run --agent-retries Recoverable retries per agent --agent-timeout-ms Per-agent timeout --token-budget Run token budget --no-auto-resume Disable usage-limit auto-resume eligibility --attach Watch a daemon-owned run until it settles --json Machine-readable output Server/UI options: --host Bind host (default: 127.0.0.1) --port Port (serve default: 4317; run --ui: automatic) --ui Start a dashboard for a locally owned run --local Ignore an already-running project daemon --detach Require an active daemon and return immediately Resume with an edited script: dynworkflow resume --script revised.workflow.js --attach One-command runtime + workflow + UI: dynworkflow serve ./audit.workflow.js --executor codex --args '{"scope":"src"}' Workflow files use the same deterministic JavaScript DSL as the Pi extension. The orchestration VM is a determinism mechanism, not a security sandbox; run only workflow scripts you trust. `; } /** * Node preserves an npm `.bin` symlink in argv[1] while resolving import.meta.url * to the package target. Compare real paths so the installed executable does * not silently look like a library import. */ export function isStandaloneCliEntrypoint( argvPath: string | undefined = process.argv[1], moduleUrl: string = import.meta.url, ): boolean { if (!argvPath) return false; try { return realpathSync(resolve(argvPath)) === realpathSync(fileURLToPath(moduleUrl)); } catch { return resolve(argvPath) === resolve(fileURLToPath(moduleUrl)); } } if (isStandaloneCliEntrypoint()) { runStandaloneCli().then( (code) => { process.exitCode = code; }, (error) => { process.stderr.write(`dynworkflow: ${errorMessage(error)}\n`); process.exitCode = 1; }, ); }