@tylerho/pi-workflows
Sandboxed multi-agent workflow orchestration, plus a deterministic multi-angle code review built on it.
Package details
Install @tylerho/pi-workflows from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@tylerho/pi-workflows- Package
@tylerho/pi-workflows- Version
0.3.0- Published
- Nov 5, 2026
- Downloads
- 251/mo · 166/wk
- Author
- tylerho
- License
- MIT
- Types
- extension
- Size
- 437.4 KB
- Dependencies
- 1 dependency · 4 peers
Pi manifest JSON
{
"extensions": [
"extensions/workflows",
"extensions/code-review"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
@tylerho/pi-workflows
Sandboxed multi-agent workflow orchestration, plus a deterministic multi-angle code review built on it.
Install
pi install npm:@tylerho/pi-workflows
Workflows
Model-authored multi-agent orchestration for pi. The workflow tool runs an inline JavaScript orchestration script in a restricted, killable child process, and each agent() call in that script becomes one isolated child AgentSession. Runs background by default, return a run id immediately, and deliver a follow-up message when they settle. Artifacts land in ~/.pi/agent/workflows/<runId>/, and resumeFromRunId replays the journal's successful agent() calls, re-running a failed or changed call and every later one.
How it works
The script holds the structure. Fan-out, verification, and synthesis live in plain JavaScript (map, filter, if, await) instead of model decisions. The tool description gates calls on explicit user opt-in: the keyword ultracode, a direct request to run a workflow or fan out agents, or a skill or slash command that says to call it. For any other task, the agent describes what a workflow could do and asks first.
Execution passes through three layers.
prepareWorkflowScript(meta.ts) parses the source with acorn and statically decodesexport const meta = {...}. Only plain object, array, and primitive literals pass, so calls, spreads, computed keys, and templates fail closed. The metadata bytes are then blanked out of the source with the line count preserved, so nothing executable survives.runWorkflowSandbox(sandbox.ts) spawns a Node child with--permission(fs-read of the worker directory only,--max-old-space-size=128,--stack-size=2048) and runs the script inside avmcontext withcodeGeneration: { strings: false, wasm: false }. The child has no imports, eval, timers, filesystem, network, or process APIs, andprocess,require, andfetchareundefined. It talks to the host only over an authenticated IPC channel, with a token of 24 random bytes and validation of every message. The body compiles as(async function __piWorkflowBody() { ... })()withagent,parallel,pipeline,phase,log,budget,workflow, andargsas parameters.RunController(controller.ts) owns the run-wide semaphore, the agent-call cap, and the abort signal. Eachagent()resolves throughrunAgent(runner.ts), which creates a fresh in-process AgentSession withSessionManager.inMemory, binds child-session extensions, and denies recursive orchestration tools.
Determinism is required because resume depends on it. Math.random(), Date.now(), and a bare new Date() throw inside the script, so a replayed call produces the same prompt. Vary work by index and pass timestamps through args. The sandbox also rejects non-yielding synchronous code through the vm timeout, unawaited agent() calls (Workflow created N unawaited agent() call(s)), and a return before in-flight agents settle.
pipeline() is the default and holds no barrier. Items flow through stages independently, and each stage callback receives (prevResult, originalItem, index). Wall-clock equals the slowest single-item chain, not the sum of stage maxima. parallel() is a barrier that awaits every thunk before returning, so it fits only a genuine cross-item dependency such as dedup before expensive downstream work or an early exit on zero findings. A throwing stage or thunk drops that item to null, and a pipeline failure also logs pipeline[index] failed: <msg>.
agent() selects a delegation tier: fast for bounded mechanical work or lookups, standard for normal coding, research, and review (the default when omitted), and deep for uncertain, cross-cutting, architectural, or adversarial work. resolveDelegationTarget() in shared/subagent-models.ts maps the tier through the user's map in extensions/shared/subagent-models.json, set with /subagents-menu, to a pi provider, model, and effort. An unavailable target fails the agent with a direct error. tier cannot be combined with model, provider, or effort; a mixed-selection error includes the tier's resolved preset so the caller can retry with the tier alone. The runner passes supportedHarnesses: ["pi"], so the claude tier fails rather than running another target. A tier's effort replaces the inherited parent thinking level, and tiered agents render as tier→resolved-id. Resolution happens when agent() is called, before a concurrency slot is taken, so a queued agent already records the model it will run, and a call that fails to resolve records no model at all rather than the parent session's.
model, provider, and effort remain for a user-requested override and bypass tiers. An override that names no model resolves to the configured subagent model (loadSubagentModels().pi), not the parent session model, so fan-out does not burn an expensive interactive model; an explicit model or provider resolves on its own and does not need the configured default to be available. model-aliases.ts resolves the model option in a chain: an exact provider/id or bare registry id, then modelAliases from workflows.json, then a known CC alias with no mapping (haiku, sonnet, opus, fable) falling back to the configured default. Exact hits pass a cost ceiling check, where exceedsCostCeiling rejects an agent-picked expensive model and affordableModels supplies alternatives. Alias-resolved models skip the ceiling because the user configured them. A broken alias target fails with a fix modelAliases error. Agents record requestedModel and render as alias→resolved-id. effort sets a thinking level (off, minimal, low, medium, high, xhigh, max), and omitting it inherits the parent level from pi.getThinkingLevel().
Run-wide caps: concurrency is DEFAULT_CONCURRENCY = min(16, max(2, cores - 2)), which is CC's formula. Total agent calls are capped at MAX_AGENT_CALLS 1000, and past it agent() resolves null. parallel() and pipeline() accept at most 4096 items and throw beyond that rather than truncating. budgetTokens, or the /workflows-budget default, sets a hard output-token ceiling. Once spent() reaches total, the next agent() throws WorkflowBudgetExceededError inside the script while in-flight agents finish. An unset budget never blocks and budget.remaining() returns Infinity.
journal.jsonl is append-only and versioned (JOURNAL_VERSION 2). Each attempt writes tagged records: started before its child runs, then result on success or failed with an error string, an optional failureKind (stall, aborted, or error), and skipped: true for a user skip. Every record carries index, label, an optional phase, the 1-based attempt, and key — the versioned lossless hash of the prompt, the full options object, and the previous call's key. Only a successful, complete result is cacheable: null, undefined, a value carrying a loss marker, a skip, and a failure are all failures, never cache entries. appendJournalEntry refuses a record over 512 KiB and returns false, and a failed append marks the run non-resumable (resumable: false plus a resumeError) while keeping its result, so the run never advertises a partial cache as usable.
createResumePlan is key-based, not a positional prefix. A live call is served a cached result only when its key verifies against the journal and no earlier call broke the replay; take(index, key, legacyKey?) returns { hit: true, result } or undefined. A failed or changed call invalidates every later call, so nothing derived from it is reused. An interrupted call — a started record with no terminal record — reruns on its own while later verified results still replay. A failed record whose failureKind is aborted came from a graceful stop or pause and invalidates the whole suffix, so a resumed run reruns the calls that were cut off. Legacy records (a bare { index, key, result } line) replay only when the key and the stored result verify.
Replay keys cover the prompt, the options, and the prior call key — not filesystem state. When a later call's cached result was computed against files an earlier interrupted call wrote, resume reruns that earlier call and still replays the later result from the pre-rerun state. Agents share ctx.cwd by default, so isolation: 'worktree' is the opt-in mitigation for concurrent file writes.
A missing, torn, oversized, or invalid journal fails the resume with a clear error rather than starting fresh (loadResumePlan also refuses while the source run is active), and the dashboard and /workflows refuse a second resume while a prior resumed run is live.
background defaults to true, and (params.background ?? true) && ctx.hasUI forces it off in a headless session, where there is no UI to deliver the follow-up to. A background run returns a launch message immediately and delivers the completion follow-up as a workflow-completion custom message through pi.sendMessage(..., { deliverAs: "followUp", triggerTurn: true }). The model-facing content is unchanged ([Background workflow <runId> <status>] plus the full report), and the registered renderer paints a digest instead of the report. A blocking run emits tool-block progress throttled at 120 ms and throws on a non-completed status, which is how pi marks the tool failure. session_shutdown aborts and settles every active run.
The live rail. A belowEditor widget (workflow-task-rail) shows the active runs. A header carries the running and finished counts, and each row carries the run name, the current phase, the settled and total agent counts, and the elapsed time. The rail renders at most 8 rows and collapses the rest into a … N more line. It re-reads the active-runs map on every render, so details that mutate in place (currentPhase, the growing agents array) stay current. A 1 Hz tick repaints the elapsed times while a run is live, and updateIndicator() repaints immediately on a launch or settle. The widget hides while nothing runs, and it has no key handling.
Each agent runs with normal trust-aware resources (createWorkflowResources calls createChildResources in shared/child-session.ts), the same childToolPolicy() denylist subagents use, and an optional one-shot structured_output tool when the script passes schema. The denylist covers subagent_spawn, subagent_wait, subagent_cancel, subagent_check, subagent_list, workflow, ask_user_question, enter_worktree, exit_worktree, and code_review. The retired wait and check names remain as guards against reintroduction. A schema must be a bounded JSON object of at most 10,000 nodes and depth 24, with no __proto__, constructor, or prototype keys, and it wraps through Type.Unsafe so every JSON Schema keyword survives. Children receive WORKFLOW_AGENT_SYSTEM_INSTRUCTION or STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION. isolation: 'worktree' runs one agent in a fresh git worktree on a new branch wf/<runId>/<agentIndex>-<slug>. It is removed when the agent left it unchanged, and kept when the working tree is dirty or the branch gained commits — the log line reports the path and branch, and the run record keeps the path, so committed work is never discarded just because the tree is clean.
Watchdogs in runner.ts bound each agent. The first assistant response event must arrive within FIRST_RESPONSE_TIMEOUT_MS 45 s, or a silent provider request aborts. Mid-run progress must arrive within STALL_TIMEOUT_MS 180 s by default, with an optional per-agent stallMs override. The watchdog suspends while a tool executes because the per-tool guard bounds that. A stalled attempt is aborted, its child awaited, then retried serially up to MAX_STALL_RETRIES 5 times after the first; a run-level stop cancels the remaining retries, and ordinary provider errors are never retried automatically. Each child tool call is limited by CHILD_TOOL_CALL_TIMEOUT_MS 30 min in shared/tool-call-timeout.ts. Structured output gets MAX_STRUCTURED_OUTPUT_ATTEMPTS 5 attempts before the agent aborts. runAgent never throws and settles every failure mode into AgentOutcome { ok, output, structured?, error?, failureKind?, aborted, usage, model, contextWindow, transcript, toolCalls }, with output truncated to 64 KiB and toolCalls counting the tool calls other than structured_output. If orchestration returns normally but at least one agent ran and every agent ended in the error state, the workflow settles as failed and records the failed-agent count and first agent error. Skipped and interrupted agents do not count as failures. Partial agent failures do not change a normally returned workflow's completed status, and a run with no agents is unaffected. When RunController.settle hits its deadline (RUN_SHUTDOWN_TIMEOUT_MS 8 s), it reports stopping and keeps the run and heartbeat active until every registered child exits. whenDrained() resolves after those tasks exit.
artifacts.ts coalesces live state to disk every WORKFLOW_CHECKPOINT_INTERVAL_MS 500 ms, runs checkpoint({ immediate: true }) on agent settle, and flushes synchronously at the end. Every write goes through writeFileAtomic, which writes a temp file and renames it.
A run also writes a liveness heartbeat, started by launch.ts when the run registers and stopped only after all child tasks exit, so a graceful end leaves a final beat and a crash lets the file age out. createWorkflowHeartbeat writes the current epoch milliseconds to <runDir>/heartbeat immediately and then every WORKFLOW_HEARTBEAT_INTERVAL_MS 5000 ms; readWorkflowHeartbeatAgeMs returns the beat's age in ms, or undefined when there is no readable file, and isRunLiveFromDisk calls a run live while that age is under WORKFLOW_HEARTBEAT_STALE_MS 30000 ms. Stop and pause first set the run to stopping and abort active children. The run remains active and its heartbeat continues until every child exits, then it becomes aborted or paused. A skipped agent resolves to null and is journaled as a failed record with skipped: true; an interrupted attempt is journaled as failed with failureKind: "aborted", and resume reruns it. Liveness comes from disk as well as module memory: the dashboard and the plain /workflows listing recover stale on-disk running or stopping runs as interrupted and mark active agents interrupted. A paused run stays paused. A fresh heartbeat keeps a run live across extension reloads and processes.
API
Tool: workflow
index.ts registers the tool through pi.registerTool({ name: "workflow", ... }). The description, snippet, and guidelines come from WORKFLOW_TOOL_DESCRIPTION, WORKFLOW_PROMPT_SNIPPET, and WORKFLOW_PROMPT_GUIDELINES (prompt.ts), and the description appends the saved-workflow registry from describeSavedWorkflows(process.cwd()).
Parameters (typebox WorkflowParams, all optional):
| Name | Type | Meaning |
|---|---|---|
script |
string | Inline JS orchestration script. Required unless name is given. |
name |
string | Name of a saved workflow to run instead. Passing script too overrides the saved definition for one run. |
args |
string | Optional JSON string exposed to the script as args. Parsed when valid JSON, otherwise passed through as the raw string. |
background |
boolean | Default true: return the run id immediately and deliver a follow-up on completion. false blocks with live progress in the tool block. |
resumeFromRunId |
string | wf_ plus 12 hex. Replays successful agent() calls from that run's journal; a failed or edited call and every later call rerun, and an interrupted call reruns on its own. An invalid id is rejected, and a missing or unreadable journal fails the resume. |
budgetTokens |
number | Output-token ceiling for this run. Floored, and a value of 0 or less falls back to the /workflows-budget default. |
Return shape:
- Background:
{ content: [{ type: "text", text: launch message }], details: compact WorkflowDetails }immediately, with empty agent transcripts, the result trimmed to 64 KiB, and the result dropped entirely when the run recordsresultOmitted. The completion follow-up carries the full report. - Blocking: the same shape after completion, and a throw of
Error(buildWorkflowResultMessage(...))when the status is notcompleted. - Errors: an unknown
name(the message lists available names), an unparseable script, and an invalidresumeFromRunId.
renderCall extracts metadata through extractMeta and shows the phases. renderResult draws a state square per agent, a phase-grouped expanded view, status colors from statusColor and stateSquare, and an expand hint from keyHint("app.tools.expand").
Script API
A script may start with export const meta = { name?, description?, whenToUse?, phases: [{ title, detail? }] }. The phases drive the progress UI, the registry, and the /workflows display. The body then has these globals, all read-only bindings.
| Global | Behavior |
|---|---|
agent(prompt, options?) |
await agent(prompt, { label?, phase?, schema?, tier?, model?, provider?, effort?, isolation?, requireWork?, stallMs? }) resolves to the final text as a string, the validated object when schema is given, or null on failure. stallMs overrides the 180-second no-progress watchdog for that agent. A skipped agent also resolves to null and records its skipped state in the run. requireWork: true fails an agent that returns an empty result and made no successful tool call other than structured_output, judging a text agent on its output text. It throws WorkflowBudgetExceededError on budget exhaustion and returns null when the call cap or an abort rejects it, so callers filter with .filter(Boolean). Reason strings live on the run record, not in the value. |
pipeline(items, stage1, stage2, ...) |
No barrier between stages. Each stage receives (prevResult, originalItem, index). A throwing stage drops that item to null and skips its remaining stages. At most 4096 items. |
parallel(thunks, { concurrency? }) |
Barrier. Zero-argument thunks, and a throwing thunk resolves to null rather than rejecting. Concurrency defaults to the run-wide cap. At most 4096 items. Results come back in order. |
phase(title) |
Marks the current phase for progress and grouping. Titles are capped at 160 characters. |
log(message) |
Emits a progress line, capped at 500 characters, which the completion report replays. |
workflow(name, args?) |
Runs a saved workflow inline as a sub-step and returns its result. The child shares this run's concurrency cap, agent counter, abort signal, and budget. One level only, because nesting throws. An unknown name or a child syntax error throws. |
budget |
{ total: number | null, spent(): number, remaining(): number }, refreshed by the host after every settled agent. |
args |
The parsed args tool parameter, deep-frozen, or undefined. |
Every agent() call must be awaited, and the script must return a JSON-serializable aggregate. Bigint becomes "<n>n", a cycle becomes "[circular]", and undefined becomes null. Imports, export default, eval, timers, process, and network access are unavailable.
Commands
| Command | Purpose |
|---|---|
/workflows-budget |
Shows the current default with no argument. Otherwise accepts 500k, 1.5m, off, none, unlimited, or a plain token count. The three words all mean unlimited. Persists to workflow-budget.json through parseBudget, saveDefaultBudget, and loadDefaultBudget. |
/workflows |
Opens the full-screen dashboard in the TUI: a run list, a per-run detail with a phases sidebar and an agents panel, then a per-agent transcript. j and k move, g and G jump, l, right, and enter descend, h, left, and esc ascend. In run selection, x stops, p pauses, and r resumes a paused or interrupted run. In the agent panel or transcript, x skips a running agent and r retries a failed, skipped, or interrupted agent after its run exits. s writes report.md. The view refreshes every 500 ms while a run is live, and opening it acknowledges finished runs and resets the footer counters. An optional runId argument opens that run directly, matched exactly or by a trailing segment of the id. Outside the TUI it prints a plain listing or offers a ctx.ui.select picker. |
Messages and renderers
| custom type | delivery | renderer |
|---|---|---|
workflow-completion |
{ deliverAs: "followUp", triggerTurn: true }, visible |
renderWorkflowCompletion |
The model-facing content stays the completion report, which ends with Result: and the serialized result or Result: not stored (<reason>) when resultOmitted is set. The renderer reads details (runId, name, status, elapsed, agents with total/done/failed/skipped/interrupted, currentPhase, artifactsDir), so the transcript never paints the report verbatim. Collapsed shows the digest (✦ workflow <name> · <status> · N agents plus a count for each non-zero failed, skipped, and interrupted bucket, then <elapsed>) plus the /workflows <runId> pointer. Expanded shows the report capped at 4000 characters and a pointer to the run directory, where result.json and report.md live. report.md is written at settle and refreshed by the dashboard s key.
Events
| Event | Handler |
|---|---|
session_start |
Captures lastUi = ctx.ui when ctx.hasUI, mounts the rail widget, and refreshes the indicator. |
session_shutdown |
Aborts every active run with Session is shutting down, settles each with abort: true, waits up to 8 s for completions, and clears the workflows footer status and the rail widget. |
updateIndicator() publishes reportRunning("workflows", activeRuns.size) to shared/agent-activity.ts, which summaries gates recaps on, and, when a UI exists, sets the workflows footer status through formatActivityStatus(theme, "workflows", { running, done, failed }). Finished-run counters stay visible until the dashboard acknowledges them.
Config files and artifacts
| Path | Contents |
|---|---|
workflow-budget.json |
In the agent directory from getAgentDir(). { "tokens": number | null }, written by /workflows-budget. |
<agent dir>/workflows/*.js |
User-level saved workflow definitions. |
<cwd>/.pi/workflows/*.js |
Project-level saved definitions, which shadow user ones of the same name. |
workflows.json |
In the agent directory. { "extraDirs": ["~/.claude/workflows"], "modelAliases": { "sonnet": "deepseek/deepseek-v4-pro" } }. loadRegistrySettings() re-reads it on every registry scan and every agent model resolution, so edits take effect immediately. |
~/.pi/agent/workflows/<runId>/ |
Run artifacts. |
A saved definition is a .js file with a meta.name and meta.description, at most 512 KiB, and an optional meta.whenToUse that shows in the registry listing. Precedence runs from extraDirs (array order, later entries winning) through the user directory to the project directory. Extra-dir entries render with [from <dir>] in the tool description, and a malformed definition is skipped rather than breaking the registry. Run artifacts live in wf_* subdirectories of the user workflow directory, so they never collide with definitions.
A run directory holds script.js, args.json when args were passed, workflow.json (compact details with the result replaced by a pointer to result.json and transcripts replaced by transcripts.json), result.json (at most 1 MiB), transcripts.json (a versioned manifest keyed by agent index), the transcripts/ shard directory, journal.jsonl, heartbeat (the liveness beat described above), and report.md. The manifest caps the aggregate across shards at 256 KiB and each shard at 32 KiB holding the initial prompt plus the newest tail, and records an explicit { omitted: true, reason } slot when an agent's transcript cannot be stored, which is distinct from an agent that produced no transcript. report.md is written at settle from the completion report and can be refreshed by the dashboard s key. When the result exceeds the 1 MiB limit the run stores resultOmitted instead, and the report renders Result: not stored (…) rather than a truncated value. hydrateWorkflowResult(runDir, details) reloads the result and transcripts from these artifacts, so plain /workflows <runId> shows what the dashboard shows; a manifest or shard that is named but unreadable reports loss instead of claiming the run predates transcript capture.
Module exports
index.ts is the entry point, and its default export is workflows(pi: ExtensionAPI). No other extension imports these modules directly. index.ts registers the run lifecycle into shared/workflow-runtime.ts, and code-review drives it through that seam.
launch.tsexportscreateLaunch(deps), a dependency-injected factory over the active-runs map, the indicator and settled-run callbacks,runAgent,createResources, and an injectable delegation-config loader. It parameterizes the core (spec.orchestrate(dsl), which is the sandbox script for the tool and an in-process engine for code-review) and delivery ("model-followup"for the background follow-up and the blocking throw,"programmatic"for a resolvedsettledwith no follow-up). The returnedlaunchmatchesWorkflowRuntime["launch"]. The internal spec and handle add the tool-only extras: resume plan, extra artifacts, blocking signal, and tool-block progress. It also exportserrorText,compactToolDetails,MAX_STALL_RETRIES(5), and the typesActiveRun,InternalLaunchSpec,InternalRunHandle, andLaunchDeps.index.tscallsregisterWorkflowRuntime({ launch })on load.prompt.tsexportsWORKFLOW_PARAMETER_DESCRIPTIONS,WORKFLOW_TOOL_DESCRIPTION,WORKFLOW_PROMPT_SNIPPET,WORKFLOW_PROMPT_GUIDELINES,buildWorkflowAgentPrompt,WORKFLOW_AGENT_SYSTEM_INSTRUCTION,STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION,STRUCTURED_OUTPUT_TOOL_DESCRIPTION,buildWorkflowResultMessage,buildBackgroundWorkflowFollowUp, andbuildBackgroundWorkflowLaunchResult.runner.tsexportsrunAgent,createWorkflowResources,guardWorkflowChildTools,recordToolExecutionTiming,transcriptFromMessages,createStallWatchdog,createFirstResponseWatchdog, the constantsFIRST_RESPONSE_TIMEOUT_MS(45 s),STALL_TIMEOUT_MS(180 s), andMAX_STRUCTURED_OUTPUT_ATTEMPTS(5), and the typesWorkflowModel,ThinkingLevel,AgentOutcome,AgentProgress,RunAgentOptions, andToolExecutionTiming.controller.tsexportsRunControllerwith.signal,.calls,.schedule(task, invocationSignal?),.abort(reason?),.settle({ abort?, timeoutMs? }), and.whenDrained().settle()seals the task registry and waits up to 8 s;whenDrained()waits for every registered task even after that deadline. Constants areDEFAULT_CONCURRENCY,MAX_AGENT_CALLS(1000), andRUN_SHUTDOWN_TIMEOUT_MS(8000).budget.tsexportsWorkflowBudget(.add(),.spent(),.remaining(),.assertAvailable(),.view()),WorkflowBudgetExceededError,loadDefaultBudget,saveDefaultBudget,parseBudget,formatBudget, and the typeBudgetView.journal.tsexportscallKey(prompt, options, previousKey?),appendJournalEntry,readJournal,loadResumePlan,createResumePlan(.take(index, key, legacyKey?)returns{ hit: true, result }or undefined, plus.servedCountand.available),JOURNAL_FILENAME,JOURNAL_VERSION(2), and the entry typesStartedJournalEntry,FailedJournalEntry,ResultJournalEntry,JournalEntry, andJournalRecord.meta.tsexportsprepareWorkflowScript(returns{ source, meta }),extractMeta, which is cached and safe in render paths, and the typesWorkflowMeta,WorkflowPhase, andPreparedWorkflowScript.model.tsexportsWorkflowDetails,AgentRecordwithtierandeffortprovenance,AgentUsageandemptyUsage,TranscriptEntry, the state and status helpersstateSquare,statusSquare,statusWord,statusColor, andSQUARE, the formattersformatElapsed,formatUsage,formatTokens,shortenHome,agentModelLabel,agentContext,aggregateUsage,countStates,phaseGroups, andresultJson, and the constantsRESULT_JSON_MAX_BYTESandRESULT_JSON_MAX_LINES.artifacts.tsexportspersistWorkflowJson,hydrateWorkflowResult,createWorkflowPersistence(.checkpoint({ immediate? })and.flush()),createWorkflowHeartbeat,readWorkflowHeartbeatAgeMs,isRunLiveFromDisk,boundedArtifactTranscript, the constantsWORKFLOW_CHECKPOINT_INTERVAL_MS(500),WORKFLOW_HEARTBEAT_INTERVAL_MS(5000),WORKFLOW_HEARTBEAT_STALE_MS(30000),ARTIFACT_TRANSCRIPT_TOTAL_MAX_BYTES(256 KiB), andWORKFLOW_TRANSCRIPT_MANIFEST_VERSION(2), and the typesWorkflowHeartbeat,WorkflowArtifactOptions,TranscriptManifest, andTranscriptManifestRecord.sandbox.tsexportsrunWorkflowSandbox, which spawns the permission-restricted child and validates every IPC message, enforcing 512 KiB of source, 256 KiB of args, a 1 MiB result, and 1000 agent requests. Types areRunWorkflowSandboxOptions,SandboxAgentOptions, andSandboxAgentResult.sandbox-child.cjsis the worker runtime. It builds thevmcontext and the globals, forbids nondeterministic clocks, refreshes the budget through__setBudget, implementsparallelandpipelinewithMAX_FANOUT_ITEMS4096, and runs the unawaited and in-flight checks.registry.tsexportslistSavedWorkflows(cwd),collectSavedWorkflows(dirs),findSavedWorkflow(cwd, name),describeSavedWorkflows(cwd),loadRegistrySettings(),userWorkflowDir(),projectWorkflowDir(cwd), and the typesSavedWorkflowandRegistrySettings.model-aliases.tsexportsresolveModelOption(lookup, model, provider, aliases), which returns an exact, alias, default, or unknown resolution,CC_MODEL_ALIASES, and the typesModelLookupandModelOptionResolution.worktree.tsexportsisGitRepo(cwd)andcreateWorktree({ cwd, runId, agentIndex, label }), which returns{ path, branch, release() }withrelease()giving{ removed, changedFiles, commits, branch }.release()removes the worktree only when the tree is clean and the branch gained no commits; otherwise it keeps both and reports them.serialization.tsexportssafeStringify,losslessJsonStringify(the canonical lossless serializer),callKey(also re-exported byjournal.ts) andlegacyCallKey,toSerializable,truncateUtf8, andwriteFileAtomic. The whole extension uses these for artifact writes.dashboard.tsexportsshowWorkflowDashboard(ctx, getActive, initialRunId?, controls?), the pureresolveWorkflowControlAction(...)helper,sessionWorkflowRunIds(ctx),loadRunEntries(...), the classWorkflowDashboard, and the typesRunEntryandWorkflowDashboardControls.rail.tsexportsMAX_RAIL_ROWS,workflowRailModel,renderWorkflowRail,createWorkflowRail, and the typesWorkflowRailRowandWorkflowRailModel. The model and the line renderer are pure, so they unit-test over a fake active-runs map.completion.tsexportsCOMPLETION_REPORT_MAX_CHARS(4000),WorkflowCompletionDetails,buildWorkflowCompletionDetails, andrenderWorkflowCompletion.prototype-scheduling/is a throwaway prototype with a scheduler simulation and TUI, run throughnpm run prototype:scheduling. It answered the barrier-versus-pipeline question in July 2026 and motivatedpipeline()and the CC concurrency formula. It is not part of the runtime surface.
Examples
- Background fan-out over a file list, with a find phase and a verify phase.
export const meta = { name: 'audit', description: 'Find and verify bugs', phases: [{title:'Find'},{title:'Verify'}] }
const findings = await parallel(args.files.map(f => () => agent('Audit ' + f + ' for bugs', { phase: 'Find', schema: FINDINGS_SCHEMA })))
const confirmed = await parallel(findings.filter(Boolean).flatMap(r => r.bugs).map(b => () => agent('Adversarially verify: ' + b.title, { phase: 'Verify', schema: VERDICT_SCHEMA })))
return { confirmed: confirmed.filter(Boolean).filter(v => v.isReal) }
Called as workflow({ script, args: JSON.stringify({ files: ['src/runner.ts'] }), background: true, budgetTokens: 400000 }). The run id comes back immediately, the completion report arrives as a follow-up message, and /workflows shows live progress.
Resume after a failure. The result message of a failed run names the resume form,
workflow({ script, resumeFromRunId: "wf_..." }). The agent edits the script and relaunches. Successful calls whose prompt and options are unchanged replay fromjournal.jsonl; a failed or edited call invalidates that call and every later one, and an interrupted call reruns on its own while later verified results still replay.A blocking run of a saved workflow. With
~/.pi/agent/workflows/review.jssaved and a project copy at<cwd>/.pi/workflows/review.jsshadowing it,workflow({ name: "review", args: '{"paths": ["src/"]}', background: false })blocks with live tool-block progress and throws if the run fails. Inside a script, the same registry is reachable asawait workflow("review", { paths: [...] }), at one nesting level, sharing the budget, caps, and abort signal.A budget-scaled loop from
WORKFLOW_TOOL_DESCRIPTION. The guard onbudget.totalkeeps an unset budget, whoseremaining()isInfinity, from running to the 1000-agent cap.
while (budget.total && budget.remaining() > 50_000) {
const result = await agent('Find bugs.', { schema: BUGS_SCHEMA })
bugs.push(...(result?.bugs ?? []))
log(`${bugs.length} found, ${Math.round(budget.remaining()/1000)}k remaining`)
}