@herbertgao/pi-subagents 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +427 -120
  3. package/docs/rpc.md +184 -0
  4. package/docs/workflows.md +466 -0
  5. package/examples/agent-tool-description.md +6 -6
  6. package/examples/workflows/compose.js +52 -0
  7. package/examples/workflows/fan-out-audit.js +56 -0
  8. package/examples/workflows/gated-fix.js +60 -0
  9. package/examples/workflows/lib/count-child.js +30 -0
  10. package/examples/workflows/review-panel.js +68 -0
  11. package/examples/workflows/structured-findings.js +81 -0
  12. package/package.json +12 -9
  13. package/src/agent-file-toggle.ts +52 -12
  14. package/src/agent-manager.ts +837 -146
  15. package/src/agent-runner.ts +213 -39
  16. package/src/cross-extension-rpc.ts +73 -14
  17. package/src/custom-agents.ts +101 -47
  18. package/src/index.ts +2249 -914
  19. package/src/invocation-config.ts +13 -0
  20. package/src/mention-clone.ts +215 -0
  21. package/src/mention.ts +147 -0
  22. package/src/model-resolver.ts +9 -1
  23. package/src/nested-tools.ts +40 -26
  24. package/src/output-file.ts +18 -8
  25. package/src/prompts.ts +46 -9
  26. package/src/schedule.ts +21 -16
  27. package/src/settings.ts +137 -7
  28. package/src/structured-output.ts +136 -0
  29. package/src/types.ts +126 -8
  30. package/src/ui/agent-mention.ts +274 -0
  31. package/src/ui/agent-widget.ts +20 -5
  32. package/src/ui/conversation-viewer.ts +14 -1
  33. package/src/ui/fleet-list.ts +167 -22
  34. package/src/ui/workflow-card.ts +555 -0
  35. package/src/ui/workflow-dialog.ts +1304 -0
  36. package/src/ui/workflow-menu.ts +226 -0
  37. package/src/workflow/collisions.ts +122 -0
  38. package/src/workflow/entry.ts +47 -0
  39. package/src/workflow/host.ts +463 -0
  40. package/src/workflow/journal.ts +164 -0
  41. package/src/workflow/json-schema.ts +142 -0
  42. package/src/workflow/meta.ts +401 -0
  43. package/src/workflow/progress.ts +622 -0
  44. package/src/workflow/runtime.ts +1399 -0
  45. package/src/workflow/saved.ts +230 -0
  46. package/src/workflow/task.ts +333 -0
  47. package/src/workflow/tool-description.ts +200 -0
  48. package/src/workflow/worker-source.ts +781 -0
  49. package/src/worktree.ts +97 -95
  50. package/src/xml.ts +13 -0
@@ -0,0 +1,466 @@
1
+ # Scripted workflows
2
+
3
+ A workflow is a small JavaScript program that spawns and coordinates many subagents: fan out over a list, push every item through the same stages, verify each result, return a summary. It runs in the background and reports as it goes.
4
+
5
+ The thing worth understanding up front is that **you do not write these — the model does.** You describe the work; it emits the script; you keep the file and re-run it. This guide is about that loop.
6
+
7
+ For the tool's parameter table and where it sits among the other tools, see [`README.md`](../README.md#subagentworkflow).
8
+
9
+ ## What a workflow is
10
+
11
+ Until workflows existed, the only way to run several agents at once was to name them one by one in a single message. That is fine for three agents you already know about. It does not work for _"audit every route file in this repo"_, where the list only exists once something has gone and looked.
12
+
13
+ A script can loop, branch, and fan out over a list discovered at runtime. A batch of tool calls cannot. Each `agent()` call in the script spawns a real subagent with its own context window, its own tools, and its own model — the script is only the coordinator, and it has no filesystem or network of its own.
14
+
15
+ Use the `Agent` tool for one delegated task, or a handful you can name up front. Reach for a workflow when the _number_ of agents depends on something discovered at runtime, when work flows through stages, or when you want findings independently verified before you believe them. It costs a subprocess per agent, so it is not the thing to dress a single task up as.
16
+
17
+ ## The lifecycle
18
+
19
+ ### 1. Ask for one
20
+
21
+ There is no `/workflows` command. The tool is model-invoked, so you get a workflow by asking for one in the prompt — the same way you ask for anything else. What you say shapes what you get:
22
+
23
+ | What you say | What you get |
24
+ | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
25
+ | "audit every route file for missing auth checks" | A discovery agent, then a fan-out over what it found |
26
+ | "review the changed files for bugs, and verify each finding before reporting it" | Two stages, the second trying to refute the first |
27
+ | "fix the failing test, and don't tell me it's done until `npm test` passes" | A `gate` on the fix agent, and a retry loop around it |
28
+ | "use a workflow to …" | Forces the shape when the model would otherwise reach for plain `Agent` calls |
29
+
30
+ You do not have to say "workflow" — the model picks the tool — but saying it removes the ambiguity when the task is borderline.
31
+
32
+ ### 2. Read what came back
33
+
34
+ The tool returns immediately. The run continues in the background and notifies you when it is done.
35
+
36
+ ```text
37
+ Workflow "auth-audit" started in the background.
38
+ Task ID: wf_9f3ab21c04de
39
+ Script: /var/folders/xy/…/pi-subagents-501/Users-me-project/<session>/tasks/wf_9f3ab21c04de.workflow.js
40
+
41
+ You will be notified when it finishes — do NOT poll or sleep waiting for it.
42
+ To iterate, edit the script file and call SubagentWorkflow again with scriptPath.
43
+ ```
44
+
45
+ Three things in there matter.
46
+
47
+ **`Task ID`** is what `resumeFromRunId` takes, and what `/agents → Workflows` lists the run under.
48
+
49
+ **`Script`** is the file to edit. **It is a scratch file in your system temp directory, not in your project** — it will not survive a reboot or a temp sweep. If the workflow turns out to be worth keeping, copy it somewhere durable; see [Save it](#5-save-it). The path means something slightly different depending on how the run was started: for an inline script it is a copy the tool just wrote, and for a run started from `scriptPath` or `name` it is _your own file_, reported straight back.
50
+
51
+ **The last line is addressed to the model, not to you.** You do not call `SubagentWorkflow` yourself — you tell the model to re-run the workflow at that path.
52
+
53
+ ### 3. Watch it run
54
+
55
+ Three surfaces, in increasing order of detail.
56
+
57
+ A **card in the transcript**, updating as the run goes:
58
+
59
+ ```text
60
+ ▸ SubagentWorkflow auth-audit 3/7 agents · 1m12s
61
+ Find routes missing auth checks, then verify each finding
62
+ ╭─ Scan
63
+ │ └─ ✔ discover · Explore · haiku 4.5 · 26.4k · 8 tool calls · 25s
64
+ ╰─ Audit
65
+ ├─ ✔ audit:src/a.ts · Explore · haiku 4.5 · 18.4k · 12 tool calls · 42s
66
+ ├─ ⟳ audit:src/b.ts · Explore · haiku 4.5 · 8 tool calls · 21s
67
+ └─ ⟳ audit:src/c.ts
68
+ ⎿ auditing 6 route files
69
+ ```
70
+
71
+ A **`workflow` row in FleetView**, above the agents, carrying its agent counts where a description would go. `⏎` on it opens the inspector rather than a conversation overlay.
72
+
73
+ Each row names the model the child _actually_ ran on — read back from its session once pi has resolved its defaults, not the string the script asked for — so a fuzzy `model: "haiku"` reads as the model it resolved to, and an `agent()` that named no model still says what it inherited.
74
+
75
+ The **inspector**, at `/agents → Workflows` — two panes, two levels: phases on the left, that phase's agents on the right, and `⏎` to descend into one agent's prompt, activity and outcome. The detail pane has room for the canonical `provider/model-id` and the thinking level, including a level pi clamped (`thinking: low (asked max)`). The full key table is in [the README](../README.md#commands); the four that change the run rather than the view are:
76
+
77
+ | Key | |
78
+ | --- | ----------------------------------------------------------------------------------- |
79
+ | `x` | Stop the run |
80
+ | `p` | Pause — running agents finish, no new ones start, and held time comes off the clock |
81
+ | `s` | **Skip** the selected agent: its `agent()` call returns `null` in the script |
82
+ | `r` | **Retry** the selected agent: the child is stopped and the same call runs again |
83
+
84
+ `s` and `r` are not view filters. Skipping an agent puts a `null` into the data your script is assembling, exactly as a terminal failure would; the script carries on with a hole in its results.
85
+
86
+ The fifth key only shows you something:
87
+
88
+ | Key | |
89
+ | --- | ---------------------------------------------------------------------------------------------------- |
90
+ | `c` | Open the selected agent's **conversation** — the same viewer a fleet-list row opens, over the dialog |
91
+
92
+ Because it changes nothing, `c` works at both levels and on an agent that has already settled — which is the usual case, since reading what a child did is most of why the inspector gets opened. The dialog hides itself while the conversation is up and comes back when you close it. A row with no child behind it yet (queued, or replayed from the resume journal) has no conversation to open and does not offer the key.
93
+
94
+ A run's own agents are not listed separately in the fleet list, the widget, the `/agents` menus or `@handle` resolution — they belong to the run, which reports for them. `c` in the inspector is the one way in to a child's conversation.
95
+
96
+ ### 4. Edit and re-run
97
+
98
+ Open the path from the `Script:` line, change it, and ask the model to run it again with `scriptPath`. That is the whole loop, and it is why the script is written to disk at all: iterating means editing a file, not asking the model to re-emit source it already produced.
99
+
100
+ Re-running normally re-pays for every agent. `resumeFromRunId` avoids that:
101
+
102
+ > Its unchanged leading `agent()` calls return their recorded results instantly; the first changed or failed call, and everything after it, runs live.
103
+
104
+ Every run journals each settled `agent()` call beside its script as `<run id>.workflow.jsonl`, and the resume replays the **unchanged prefix** of that journal. It is a prefix and not a lookup table on purpose: a later call that still matches came from a run whose earlier stages no longer exist, so its recorded answer was produced downstream of work that has changed.
105
+
106
+ Four things it will not do:
107
+
108
+ - **Cross sessions.** The journal is keyed to the session that wrote it. Restart pi and the run id is dead — you get `No workflow run "<id>" in this session.`
109
+ - **Resume a live run.** Stop it from `/agents → Workflows` first; while it is running you get `Workflow "<id>" is still running.`
110
+ - **Replay a failure.** A journaled failure ends the prefix, so resuming a run that died at agent 5 retries exactly agent 5. That is the point.
111
+ - **Replay a run that used `agent({ resume })` at all.** A replayed agent is text from a file rather than a live child, so there would be no conversation left for a later `resume` to continue.
112
+
113
+ Replayed rows are annotated `from resume journal` on the card and in the inspector, and the completion notification counts them — a resume never quietly looks like a run that was simply fast. Passing only `resumeFromRunId`, with no script of its own, re-runs that run's own script.
114
+
115
+ ### 5. Save it
116
+
117
+ A script you will run more than once belongs somewhere durable. Copy it out of the temp directory into one of these, named `<name>.js`:
118
+
119
+ | Location | Scope |
120
+ | --------------------------------------- | ----------------------------------------------- |
121
+ | `<project>/.pi/workflows/<name>.js` | This project. Checked in, if you want it shared |
122
+ | `<project>/.agents/workflows/<name>.js` | This project, in the tool-agnostic directory |
123
+ | `<agent dir>/workflows/<name>.js` | You, everywhere — follows you across projects |
124
+
125
+ First hit wins, in that order, so a project file shadows a same-named global one.
126
+
127
+ The file must carry an `export const meta = { name, description }` declaration. Those are ordinary directories that may hold anything, so that declaration is what marks a file as a workflow — name something else and you are told it is not a workflow rather than getting a parse error from halfway through it. Nothing in the file is executed to decide that.
128
+
129
+ Then invoke it by name: _"run the auth-audit workflow"_. The model passes `name: "auth-audit"` and the run reports that file back as its `Script:`, so the edit-and-re-run loop still works on it.
130
+
131
+ **Nothing lists your saved workflows for you.** `/agents → Workflows` is a _run_ inspector scoped to the current session, not a workflow browser — with five workflows saved on disk it will show you nothing. You reach a saved workflow by naming it to the model, or with [`--subagents-workflow-file=`](../README.md#cli-flags). Keeping the names memorable is on you.
132
+
133
+ ### 6. Parameterize it
134
+
135
+ A workflow that hardcodes `src/routes/` is a one-off. Take the target from `args` instead, and it becomes reusable:
136
+
137
+ ```js
138
+ export const meta = {
139
+ name: "audit",
140
+ description: "Audit a directory for missing auth checks",
141
+ }
142
+
143
+ const root = args?.root ?? "src/"
144
+ const listing = await agent(
145
+ `List every file under ${root}. One path per line, nothing else.`,
146
+ )
147
+ ```
148
+
149
+ `args` is whatever was passed to the tool, verbatim, and it must be JSON-shaped. Now _"run the audit workflow against src/api"_ and _"…against src/admin"_ are the same workflow.
150
+
151
+ ## A worked example
152
+
153
+ The task: _"find routes that don't check auth, and don't just take the first answer — check each finding."_
154
+
155
+ The model writes something like this, and the run starts:
156
+
157
+ ```js
158
+ export const meta = {
159
+ name: "auth-audit",
160
+ description: "Find routes missing auth checks, then verify each finding",
161
+ phases: [{ title: "Scan" }, { title: "Audit" }, { title: "Verify" }],
162
+ }
163
+
164
+ phase("Scan")
165
+ const listing = await agent(
166
+ "List every route file under src/routes/. One path per line, nothing else.",
167
+ )
168
+ const files = listing
169
+ .split("\n")
170
+ .map((s) => s.trim())
171
+ .filter(Boolean)
172
+ log(`auditing ${files.length} route files`)
173
+
174
+ phase("Audit")
175
+ const findings = await pipeline(
176
+ files,
177
+ (file) =>
178
+ agent(`Audit ${file} for missing auth checks. Report findings or "none".`, {
179
+ label: `audit:${file}`,
180
+ }),
181
+ (found, file) =>
182
+ agent(`Try to REFUTE this finding about ${file}: ${found}`, {
183
+ label: `verify:${file}`,
184
+ phase: "Verify",
185
+ }),
186
+ )
187
+
188
+ return findings.filter(Boolean)
189
+ ```
190
+
191
+ It works, but the result is a wall of prose you cannot sort — every verify agent answered in its own shape. So: edit the file, give the verify stage a `schema`, and return objects.
192
+
193
+ ```js
194
+ const VERDICT = {
195
+ type: "object",
196
+ properties: {
197
+ file: { type: "string" },
198
+ holds: { type: "boolean" },
199
+ why: { type: "string" },
200
+ },
201
+ required: ["file", "holds"],
202
+ }
203
+
204
+ const findings = await pipeline(
205
+ files,
206
+ (file) =>
207
+ agent(`Audit ${file} for missing auth checks. Report findings or "none".`, {
208
+ label: `audit:${file}`,
209
+ }),
210
+ (found, file) =>
211
+ agent(`Try to REFUTE this finding about ${file}: ${found}`, {
212
+ label: `verify:${file}`,
213
+ phase: "Verify",
214
+ schema: VERDICT,
215
+ }),
216
+ )
217
+
218
+ return findings.filter(Boolean).filter((f) => f.holds)
219
+ ```
220
+
221
+ Only the second stage changed, so re-running with `resumeFromRunId` replays the whole `Scan` phase and every `audit:` call from the journal, and pays only for the verify agents. The notification says so: `… , 7 replayed from wf_9f3ab21c04de`.
222
+
223
+ Then it earns its keep — copy it to `.pi/workflows/auth-audit.js`, swap `src/routes/` for `args?.root ?? 'src/routes/'`, and from then on it is _"run auth-audit against src/api"_.
224
+
225
+ The finished script ships as [`examples/workflows/fan-out-audit.js`](../examples/workflows/fan-out-audit.js).
226
+
227
+ ## Writing the script yourself
228
+
229
+ Occasionally you will want to write or heavily edit one. The rules are short.
230
+
231
+ **`meta` must be a pure literal** — no variables, function calls, spreads, or template interpolation. It is read _before_ the script runs, which is what lets the phases appear on screen from the first frame instead of materializing one at a time as agents happen to start. `name` and `description` are required; `phases` and `whenToUse` are optional.
232
+
233
+ ```js
234
+ export const meta = {
235
+ name: "my-workflow",
236
+ description: "One line, shown on the card and in the permission prompt",
237
+ phases: [{ title: "Scan", detail: "grep for candidates" }, { title: "Fix" }],
238
+ }
239
+ ```
240
+
241
+ **The body is an async function body.** Top-level `await` and a bare top-level `return` are both allowed — the runtime wraps it. (This is also why a workflow script is not valid standalone JavaScript, and why your editor may complain about the `return`.)
242
+
243
+ **What you `return` crosses a JSON boundary.** It is checked for cycles, non-finite numbers, sparse arrays, symbol keys and exotic prototypes. Return something useful on its own — the caller sees your return value, not the individual agent outputs.
244
+
245
+ ## Reference
246
+
247
+ ### Tool parameters
248
+
249
+ | Parameter | Type | Description |
250
+ | ----------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------- |
251
+ | `script` | string | Inline source. Must begin with `export const meta = { name, description }` |
252
+ | `scriptPath` | string | A script file, absolute or project-relative. **Takes precedence over `script`** — this is how an edited workflow is re-run |
253
+ | `name` | string | A saved workflow — `<name>.js` in one of the three directories above. Lowest precedence |
254
+ | `args` | any | Handed to the script as the `args` global, verbatim. Must be JSON-shaped |
255
+ | `resumeFromRunId` | string | Replay an earlier run in this session. Matches `^wf_[a-z0-9-]{6,}$` |
256
+ | `title` / `description` | string | Accepted and ignored — for Claude Code parity, so a ported call does not fail. A workflow is named by its `meta` block |
257
+
258
+ At least one of `script` / `scriptPath` / `name` is required; `scriptPath` wins over `script`, which wins over `name`.
259
+
260
+ ### `agent(prompt, opts?)`
261
+
262
+ Spawns one subagent and resolves to its final text — or, with `schema`, to a validated object.
263
+
264
+ **Returns `null` if the agent failed terminally _or_ if you skipped it from the inspector**, indistinguishably. Filter with `.filter(Boolean)` when a `null` would break a later stage, and be careful with in-script retry loops: retrying on `null` will re-run something you deliberately skipped.
265
+
266
+ | Option | Type | Notes |
267
+ | ----------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
268
+ | `label` | string | Display name in the progress tree. Also the handle `resume` addresses |
269
+ | `phase` | string | Put this agent in a named group, overriding the ambient `phase()`. **Use it inside `pipeline`/`parallel` stages**, where the ambient phase races |
270
+ | `agentType` | string | Which agent definition to use. Defaults to `general-purpose`; built-ins are `general-purpose`, `Explore`, `Plan`, plus your custom agents |
271
+ | `model` | string | `provider/modelId`, or fuzzy like `haiku` |
272
+ | `effort` | string | `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Omitted, the agent definition's own `thinking` decides, then the parent's |
273
+ | `isolation` | `"worktree"` | Run in a throwaway git worktree. Only when agents write files in parallel and would collide — it costs setup time and disk per agent |
274
+ | `gate` | string | A shell command run after the agent finishes; a non-zero exit fails the agent and its output becomes the error |
275
+ | `resume` | string | Continue the child that ran under that label instead of starting fresh |
276
+ | `schema` | object | A JSON Schema with an object root. Resolves to the validated object instead of text |
277
+
278
+ Any other key is rejected **by name** at the call. Note that this checks option _keys_, not option _values_ — an `agentType` that names no known agent falls back to `general-purpose` silently.
279
+
280
+ Combination rules: `resume` cannot be combined with `agentType`, `model`, `effort`, `isolation`, `gate` or `schema` — a resumed child keeps the agent type, model and tree it was started with, and its session predates the `StructuredOutput` tool.
281
+
282
+ ### `pipeline()` and `parallel()`
283
+
284
+ ```js
285
+ await pipeline(items, ...stages) // no barrier between stages
286
+ await parallel(thunks) // barrier: waits for all of them
287
+ ```
288
+
289
+ **`pipeline` has no barrier.** Item A can be in stage 3 while item B is still in stage 1, so the total time is the slowest single _chain_ rather than the sum of the slowest per stage. Each stage receives `(previousResult, originalItem, index)` — the original item and its index stay available in later stages, so you do not have to thread them through the return value. A stage that throws drops that one item to `null` and skips its remaining stages.
290
+
291
+ **`parallel` is a barrier.** It waits for everything before anything moves on, so if five agents run and the slowest takes three times the fastest, four sit finished doing nothing. A thunk that throws becomes `null` without taking its siblings down.
292
+
293
+ Prefer `pipeline` unless a stage genuinely needs every prior result _together_ — deduplicating across the whole set, deciding whether to continue at all, or a prompt that compares one result against all the others. Needing to flatten, map or filter is not such a case; do that inside a pipeline stage.
294
+
295
+ ### `workflow(nameOrRef, args?)`
296
+
297
+ Runs a saved workflow inline and returns its value. Pass a name, or `{ scriptPath }`. `args` becomes the child's `args` global.
298
+
299
+ The child runs in the _same_ worker and vm context under its own globals, so it shares this run's concurrency cap, agent counter, abort signal, journal and budget by construction — its agents are simply this run's agents, controllable from the same inspector. What it does not share is phase state: the child's phases render as their own `▸ <name>` group.
300
+
301
+ **One level only** — `workflow()` inside a child throws saying so. An unknown name, an unreadable path, a child carrying no `meta`, or a child that will not parse all throw into the calling script, so `try`/`catch` if you want to handle them. Capped at 256 nested calls per run.
302
+
303
+ ### `phase()`, `log()`, `args`, `budget`
304
+
305
+ - **`phase(title)`** — start a new progress group; subsequent `agent()` calls are grouped under it. Inside `pipeline`/`parallel` stages use the `phase` _option_ instead, since the ambient phase races.
306
+ - **`log(message)`** — a progress line under the tree, for you to read.
307
+ - **`args`** — whatever was passed as the tool's `args`, verbatim; `undefined` if none.
308
+ - **`budget`** — `{ total, spent(), remaining() }`. **`total` is always `null` here**: it comes from a token-target directive pi does not have. That is deliberate rather than broken — Claude Code scripts guard on it (`while (budget.total && budget.remaining() > 50_000)`), and those guards correctly do not fire instead of throwing on a missing global. `remaining()` is `Infinity` with no target. `spent()` is real, and counts output tokens this run's agents have used.
309
+
310
+ ### Where files live
311
+
312
+ | What | Where |
313
+ | ------------------------ | ---------------------------------------------------------------------------------- |
314
+ | An inline script, as run | `<tmp>/pi-subagents-<uid>/<encoded-cwd>/<session>/tasks/<run id>.workflow.js` |
315
+ | The resume journal | the same directory, `<run id>.workflow.jsonl` |
316
+ | Saved workflows | `.pi/workflows/` → `.agents/workflows/` → `<agent dir>/workflows/`, first hit wins |
317
+
318
+ The first two are scratch: temp storage, wiped by a reboot or a temp sweep. Only the third is durable, and copying a script there is a manual step.
319
+
320
+ ### Limits and caps
321
+
322
+ | Limit | Value |
323
+ | ---------------------------------------- | ---------------------------------------------------- |
324
+ | Agents running at once | `max(1, min(16, cpus - 2))` — 6 on an 8-core machine |
325
+ | Agents per run, total | 1000 |
326
+ | Items per `parallel`/`pipeline` **call** | 4096 |
327
+ | Nested `workflow()` calls per run | 256 |
328
+ | Script length | 512 KiB |
329
+
330
+ These are three different things and are easy to conflate: 1000 is a budget for the whole run, the concurrency figure is how many run _simultaneously_, and 4096 is per call rather than per run. Excess items queue rather than melting the machine.
331
+
332
+ Above 25 scheduled agents, or 1.5M tokens actual or projected, the card adds `⚠ Large workflow · /agents → Workflows to stop`.
333
+
334
+ A run's concurrency limit is its own, independent of the session's `maxConcurrent` and `maxConcurrentForeground` pools — its agents do not enter either.
335
+
336
+ ### Settings and the CLI flag
337
+
338
+ `workflowsEnabled` is **on**; leaving it unset means _auto_, which is on unless another extension already offers a `Workflow` or `SubagentWorkflow` tool, in which case this one stands down for the session. Setting it explicitly pins it. See [Persistent settings](../README.md#persistent-settings).
339
+
340
+ `pi --subagents-workflow-file=<path>` runs a workflow at startup, including headless under `pi -p`. Use the `=` form — the bare `--flag value` spelling swallows the next argument. See [CLI flags](../README.md#cli-flags).
341
+
342
+ ## Recipes
343
+
344
+ The orchestration patterns themselves — adversarial verification, judge panels, loop-until-dry — live in exactly one place: the tool description the model reads on every turn. It already knows them. So these are not instructions for writing scripts by hand; they are **what to ask for**, and what the resulting script looks like so you can recognize it in the file.
345
+
346
+ ### Fan out over a list you don't have yet
347
+
348
+ > _"audit every route file under src/routes for missing auth checks"_
349
+
350
+ One discovery agent, then `pipeline` over what it returned. Recognize it by: a lone `await agent(...)` producing a string, a `.split('\n')`, then `pipeline(files, …)`.
351
+
352
+ See [`fan-out-audit.js`](../examples/workflows/fan-out-audit.js).
353
+
354
+ ### Get objects back instead of prose
355
+
356
+ > _"…and give me the results as structured data I can sort by severity"_
357
+
358
+ Recognize it by a `const SCHEMA = { type: 'object', … }` near the top and `schema: SCHEMA` on the `agent()` calls. Worth asking for whenever the script has to _do_ anything with the results rather than hand them to you.
359
+
360
+ See [`structured-findings.js`](../examples/workflows/structured-findings.js).
361
+
362
+ ### Verify by running, not by asking
363
+
364
+ > _"fix it, and don't report success unless `npm test` passes"_
365
+
366
+ Recognize it by `gate: 'npm test'` on the fix agent. An LLM judging whether a fix works is a weaker signal than the test suite; this is the difference between a result that is _verified_ and one that is merely _claimed_.
367
+
368
+ See [`gated-fix.js`](../examples/workflows/gated-fix.js).
369
+
370
+ ### Keep an agent's context instead of re-paying for it
371
+
372
+ > _"if the tests still fail, tell the same agent what broke and let it try again"_
373
+
374
+ Recognize it by `label: 'fix'` on the first call and `resume: 'fix'` on the second. A gate-rejected child stays resumable, which is what makes "here is what the tests said, fix it" a loop rather than a fresh start.
375
+
376
+ Also in [`gated-fix.js`](../examples/workflows/gated-fix.js).
377
+
378
+ ### Several opinions, then a synthesis
379
+
380
+ > _"review this from a correctness, security and performance angle, then reconcile them"_
381
+
382
+ This is the case where a barrier is _earned_ — the synthesis agent's prompt genuinely needs every review at once. Recognize it by `parallel([...])` followed by a single `agent()` that interpolates all of the results.
383
+
384
+ See [`review-panel.js`](../examples/workflows/review-panel.js).
385
+
386
+ ### Reuse a workflow inside another
387
+
388
+ > _"map the repo first, then run the audit against what it found"_
389
+
390
+ Recognize it by `await workflow('repo-map', { … })`. Reach for it to reuse something already saved, not to structure one script — inline composition is cheaper.
391
+
392
+ See [`compose.js`](../examples/workflows/compose.js).
393
+
394
+ ## Troubleshooting
395
+
396
+ **The run failed with `… is unavailable in workflow scripts (breaks resume)`.**
397
+ The script called `Date.now()`, `new Date()` or `Math.random()`. A script that varies run to run cannot be replayed from its journal, so these throw. Use the loop index for ids, pass timestamps in through `args`, or stamp them after the workflow returns. This most often bites pasted-in helper code, and it throws at the line that calls it — _after_ you have already paid for the preceding agents.
398
+
399
+ **`The meta object must be a PURE LITERAL — no variables, function calls, spreads, or template interpolation.`**
400
+ `meta` is evaluated before the script runs, in an empty context, so it cannot reference anything. Move the dynamic part into the body.
401
+
402
+ **`agent() opts.<key> is not a recognised option.`**
403
+ A typo, or an option from a different tool. The supported set is `label`, `phase`, `model`, `agentType`, `isolation`, `gate`, `resume`, `effort`, `schema`.
404
+
405
+ **An agent ran as the wrong type and nothing said so.**
406
+ An `agentType` that names no known agent falls back to `general-purpose` **silently** — unlike the `Agent` tool, which tells you. Option _keys_ are validated; option _values_ are not. Check the spelling against `/agents`; matching is case-insensitive, and a disabled agent does not count.
407
+
408
+ **`agent()` returned `null`.**
409
+ The agent failed terminally, or you skipped it with `s` in the inspector. These are indistinguishable to the script. With `schema`, it also covers a child that never produced a payload matching the schema.
410
+
411
+ **A `schema` call came back as `null` even though the agent clearly answered.**
412
+ `schema` is pressure, not a guarantee. The child gets a `StructuredOutput` tool, `constrainedSampling` set to `strict: "prefer"`, and a validation-and-retry round trip — three soft pressures, where Claude Code has one hard one (it can force the tool call; this cannot, because `toolChoice` is not plumbed through pi's `AgentSession`). Keep schemas small and flat, and `.filter(Boolean)` after every schema stage.
413
+
414
+ **The run failed complaining about an un-awaited `agent()`.**
415
+ A dropped `await`, usually inside a `pipeline` stage. The run would otherwise finish while children were still working and throw their results away, so it fails instead — immediately rather than draining, since an agent that ignores its abort signal would wedge the run forever.
416
+
417
+ **`Cannot run with isolation: "worktree"`.**
418
+ Not a git repo, no commits yet, or `git worktree add` failed. Isolation is a strict guarantee rather than a hint, so it fails loudly instead of quietly running in your main tree. Initialize git and commit at least once, or drop the option.
419
+
420
+ **`No saved workflow named "x". Looked in: …`**
421
+ The file is not in any of the three directories, or it is there but carries no `export const meta =` declaration, so it is not recognized as a workflow. The message lists the directories it searched and any workflows it did find.
422
+
423
+ **The run seems stuck with agents queued.**
424
+ Concurrency is capped at `max(1, min(16, cpus - 2))`. Queued agents start as slots free. A pause (`p`) also holds new starts while letting running agents finish.
425
+
426
+ ## What workflows can't do
427
+
428
+ - **No filesystem, network or module access inside the script.** All real work happens in the agents it spawns, which have their normal tools.
429
+ - **No `eval` or `Function(...)`** — code generation is off in the vm; they throw `EvalError`.
430
+ - **No cross-session resume.** Journals are per session.
431
+ - **No resume at all for `--subagents-workflow-file` runs** — that path never journals.
432
+ - **No UI that lists or launches saved workflows.** The inspector shows this session's runs.
433
+ - **No scheduled workflows.** The scheduler runs agents, not workflows.
434
+ - **Results are not persisted** beyond the journal and the transcript card.
435
+ - **No driving one from another extension.** A workflow cannot be started or steered over the `pi.events` bus, and its agents are invisible to the RPC surface — they emit no lifecycle events, and `subagents:rpc:stop` refuses them. See [`rpc.md`](rpc.md).
436
+
437
+ The sandbox is a determinism and accident boundary, not a defence against a deliberately hostile script: the injected globals are host closures, and disabled code generation is what actually stops one being used to compile anything.
438
+
439
+ ## Coming from Claude Code
440
+
441
+ This is a port of Claude Code's `Workflow` tool down to its state model, so **a script written for Claude Code runs here unchanged.** `test/workflow-claude-code-compat.test.ts` runs the canonical `review-changes` example from that tool's own description, verbatim.
442
+
443
+ Identical: `agent()`, `pipeline()`, `parallel()`, `workflow()`, `phase()`, `log()`, `args`, `budget`; the `meta` block; `schema` returning a validated object; one-level `workflow()` nesting; the determinism throws.
444
+
445
+ Different:
446
+
447
+ - The tool is **`SubagentWorkflow`**, not `Workflow` — pi's tool registry is flat across extensions, and the winner of a name clash also overwrites the loser's description.
448
+ - **`budget.total` is always `null`**, because pi has no token-target directive. Claude Code's `budget.total`-guarded patterns therefore run unchanged, just without firing.
449
+ - **`schema` is pressured, not forced** — see the troubleshooting entry above.
450
+ - If both extensions are loaded, this one **stands down** rather than offering the model two orchestrators.
451
+
452
+ Additions on this side: `gate`, `resume`, `effort`, journal-backed `resumeFromRunId`, and the un-awaited-`agent()` check. All are optional, which is what keeps a Claude Code script portable.
453
+
454
+ ## Examples
455
+
456
+ Every file below is executed by `test/workflow-examples.test.ts` against a stub host on each CI run, so none of them can silently rot.
457
+
458
+ | File | Demonstrates | Runs as-is? |
459
+ | ------------------------------------------------------------------------ | ------------------------------------------------------- | -------------------------------- |
460
+ | [`fan-out-audit.js`](../examples/workflows/fan-out-audit.js) | Runtime fan-out, `pipeline`, `label`, per-stage `phase` | Yes — takes `args.root` |
461
+ | [`structured-findings.js`](../examples/workflows/structured-findings.js) | `schema` on both stages, objects instead of prose | Yes |
462
+ | [`gated-fix.js`](../examples/workflows/gated-fix.js) | `gate`, `isolation: "worktree"`, `resume` retry loop | Needs a real test command |
463
+ | [`review-panel.js`](../examples/workflows/review-panel.js) | An earned `parallel` barrier, `effort` tiering, `model` | Yes |
464
+ | [`compose.js`](../examples/workflows/compose.js) | `workflow()` nesting and `args` plumbing | Needs `lib/count-child.js` saved |
465
+
466
+ Copy one into `.pi/workflows/` to make it yours.
@@ -14,12 +14,12 @@ If the target is already known, use a direct tool — `read` for a known path, `
14
14
  ## Usage notes
15
15
 
16
16
  - Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
17
- - When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently.
17
+ - When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently. If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple Agent tool use content blocks.
18
18
  - When the agent is done, it returns a single message back to you. The result is not visible to the user — to show the user, send a text message with a concise summary.
19
- - Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting work as done.
20
- - Agents run in the background by default. You will be notified when one completes — do NOT poll or sleep waiting for it.
21
- - Pass `run_in_background: false` only when your very next action depends on the result and nothing else could usefully happen while it runs.
22
- - Never fabricate or predict a pending agent's results; if asked before completion, say it is still running.
19
+ - Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting the work as done.
20
+ - Agents run in the background by default. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead.
21
+ - **Foreground vs background**: Pass `run_in_background: false` only when your very next action depends on the agent's result and nothing else could usefully happen while it runs — e.g., a research agent whose finding gates the edit you're about to make. Otherwise let it run in the background (the default) — this includes fire-and-forget work, independent investigations, and anything where the user might hand you something else in the meantime. Wanting the result "next" is not enough on its own.
22
+ - **Don't race**: after launching a background agent, you know nothing about its results. Never fabricate or predict them in any format — not as prose, summary, or structured output. The completion notification arrives in a later turn; it is never something you write yourself. If the user asks before it lands, say the agent is still running — give status, not a guess.
23
23
  - Use resume with an agent ID to continue a previous agent's work. A new (non-resume) Agent call starts a fresh agent with no memory of prior runs, so the prompt must be self-contained.
24
24
  - Use steer_subagent to send mid-run messages to a running background agent.
25
25
  - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
@@ -30,7 +30,7 @@ If the target is already known, use a direct tool — `read` for a known path, `
30
30
 
31
31
  ## Writing the prompt
32
32
 
33
- Provide clear, detailed prompts so the agent can work autonomously. Brief it like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
33
+ Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
34
34
  - Explain what you're trying to accomplish and why.
35
35
  - Describe what you've already learned or ruled out.
36
36
  - Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * compose.js — reuse a saved workflow inside another one.
3
+ *
4
+ * Demonstrates: `workflow(nameOrRef, args?)`, `args` plumbing into the child,
5
+ * and catching the failures a bad reference throws.
6
+ *
7
+ * The child runs in the SAME worker and vm context under its own globals, so it
8
+ * shares this run's concurrency cap, agent counter, abort signal, journal and
9
+ * budget by construction — its agents are simply this run's agents, visible and
10
+ * controllable from the same inspector. What it does not share is phase state:
11
+ * the child's phases render under their own `▸ count-child` group.
12
+ *
13
+ * Nesting is ONE level deep. A `workflow()` call inside the child throws.
14
+ *
15
+ * Requires `lib/count-child.js` to be resolvable — copy both this file and the
16
+ * child into `.pi/workflows/` (the child as `count-child.js`) before running it
17
+ * by name.
18
+ *
19
+ * args: { root?: string }
20
+ *
21
+ * Run: ask the model — "run the workflow at examples/workflows/compose.js".
22
+ */
23
+ export const meta = {
24
+ name: "compose",
25
+ description:
26
+ "Count files with a nested workflow, then summarize what it found",
27
+ phases: [{ title: "Count" }, { title: "Summarize" }],
28
+ }
29
+
30
+ const root = args?.root ?? "src/"
31
+
32
+ phase("Count")
33
+
34
+ // An unknown name, an unreadable path, a file with no `meta`, or a child that
35
+ // will not parse all throw into this script — so catch if you want to carry on.
36
+ let count
37
+ try {
38
+ count = await workflow("count-child", { root })
39
+ } catch (error) {
40
+ log(`nested workflow failed: ${error.message}`)
41
+ return { ok: false, reason: error.message }
42
+ }
43
+
44
+ log(`the child counted ${count} files under ${root}`)
45
+
46
+ phase("Summarize")
47
+ const summary = await agent(
48
+ `There are ${count} source files under ${root}. In one sentence, say whether that is a lot for a project of this kind.`,
49
+ { label: "summarize", effort: "low" },
50
+ )
51
+
52
+ return { ok: true, count, summary }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * fan-out-audit.js — the canonical workflow shape.
3
+ *
4
+ * Demonstrates: a fan-out whose width is discovered at runtime, `pipeline`
5
+ * (no barrier between stages), `label` for readable progress rows, and a
6
+ * per-stage `phase` override.
7
+ *
8
+ * args: { root?: string } — directory to audit, default "src/routes/"
9
+ *
10
+ * Run: ask the model — "run the workflow at examples/workflows/fan-out-audit.js
11
+ * against src/", or copy it to .pi/workflows/ and ask for it by name.
12
+ */
13
+ export const meta = {
14
+ name: "fan-out-audit",
15
+ description:
16
+ "Find files missing auth checks, then try to refute each finding",
17
+ phases: [{ title: "Scan" }, { title: "Audit" }, { title: "Verify" }],
18
+ }
19
+
20
+ const root = args?.root ?? "src/routes/"
21
+
22
+ phase("Scan")
23
+ const listing = await agent(
24
+ `List every source file under ${root}. One path per line, nothing else.`,
25
+ { label: "discover" },
26
+ )
27
+ const files = listing
28
+ .split("\n")
29
+ .map((s) => s.trim())
30
+ .filter(Boolean)
31
+ log(`auditing ${files.length} files under ${root}`)
32
+
33
+ // pipeline, not parallel: a file that finishes auditing moves straight to
34
+ // verification instead of waiting for the slowest sibling to catch up.
35
+ phase("Audit")
36
+ const findings = await pipeline(
37
+ files,
38
+ (file) =>
39
+ agent(
40
+ `Audit ${file} for missing auth checks. Report findings, or "none".`,
41
+ {
42
+ label: `audit:${file}`,
43
+ },
44
+ ),
45
+ // Later stages still receive the original item — no need to thread it through
46
+ // the previous stage's return value.
47
+ (found, file) =>
48
+ agent(`Try to REFUTE this finding about ${file}: ${found}`, {
49
+ label: `verify:${file}`,
50
+ // Explicit, because the ambient phase() races inside pipeline stages.
51
+ phase: "Verify",
52
+ }),
53
+ )
54
+
55
+ // A skipped or failed agent is a null, so filter before returning.
56
+ return findings.filter(Boolean)