@uninspired/pi-subagent-pipeline
Configurable subagent rosters for Pi: write a profile, then run work as an observable pipeline
Package details
Install @uninspired/pi-subagent-pipeline from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@uninspired/pi-subagent-pipeline- Package
@uninspired/pi-subagent-pipeline- Version
0.0.2- Published
- Oct 2, 2026
- Downloads
- 336/mo · 15/wk
- Author
- herbius
- License
- MIT
- Types
- extension, skill
- Size
- 756 KB
- Dependencies
- 0 dependencies · 4 peers
Pi manifest JSON
{
"image": "https://raw.githubusercontent.com/UninspiredStudio/pi-subagent-pipeline/main/assets/icon-master.png",
"skills": [
"./skills"
],
"extensions": [
"./src/index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-pipeline
A single agent context is a bottleneck. Everything a task touches — the code, the logs, the dead ends — piles into one conversation, and the model paying for that pile is also the model doing the work. One model cannot be both the cheap reader of many files and the expensive judge of the result.
pi-pipeline splits the work, and it does so from a roster you control:
- A profile is the subagent roster. It declares which subagents exist, each with its own model, provider, thinking effort, tool allowlist, access class, spawn rights, escalation target and prompt — plus the run envelope (budget, depth, concurrency, retention).
- A pipeline is one task decomposed into steps, each step run as a child agent session.
The parent stays small; the children carry the reading, the writing and the judgment. Delegation is a default path, not a special case: the parent picks roles from the roster, orders the steps, and gets back a live tree plus a run directory it can resume.
Install
pi install npm:@uninspired/pi-subagent-pipeline
Then /reload (or restart Pi) and check /pipeline-doctor: the session exposes the pipeline and pipeline_status tools and registers the pipeline skill. Working on the plugin itself, install the checkout instead — pi install /path/to/pi-subagent-pipeline loads it in place, so edits take effect after /reload.
pi-pipeline and pi-subagents must not both be active: both inject delegation guidance and both draw a run UI. /pipeline-doctor names any second delegation tool it finds (subagent, subagents_enable).
How it works
One run, end to end.
- The parent calls
pipeline({ steps }). Each step needsid,role,objectiveanddeliverable; optional arescope,context,needs,model,thinkingandtouches. The plan is validated before anything spends tokens: unknown roles, missing fields, duplicate ids,needson an unknown step,needson itself, dependency cycles andmaxNodesbreaches are all rejected by name. - Each step becomes a child session created from its role. The child gets the role's model, thinking effort, tool allowlist and role prompt. The role prompt is appended to Pi's system prompt so children keep Pi's built-in tool guidance. A child gets the
pipelinetool itself only while the depth cap allows, and only for roles it is allowed to spawn. - Steps are ordered by
needs, not array position. Independent read steps run in parallel, clamped to the profile'smaxConcurrent. A write step always runs alone. A reader that could observe a partial write is promoted after that writer, and the basis — declaredtouches, inferred from text, or conservative — is recorded and reported as a warning. - Every child ends with a sentinel.
PIPELINE_STATUS: okorPIPELINE_STATUS: blocked - <reason>. Only the last non-empty line counts. A child that produced no output at all is failed; a missing sentinel with output is treated asok, so a forgotten line alone never spends escalation money. - Blocked and failed steps escalate. Escalation fires only when a step ends
blocked— an explicitPIPELINE_STATUS: blocked, or a failedverify.command— or when it produced no output at all (recorded asfailedwith errorchild produced no output). A timeout returnsstopped, and a thrown exception lands asfailedin the catch block before the escalation check, so neither escalates. The target is validated at activation, self-escalation is rejected, and attempts and total escalations are capped. Averify.commandruns in the workspace after the step; its exit code is the verdict, and a non-zero exit marks the step blocked — exactly the escalation trigger. - Changed files come from the child's own
write/edittool events, not from what the child claims. A write outside the workspace is recorded separately and never glob-matched againsttouches. - The run is written down and drawn live. Each run gets a run directory (below), and the tree renders under the tool call while children stream.
- Resume re-runs only the non-
oknodes.pipeline({ resume: runId })or/pipeline-resume <runId>replays the recorded steps as a new round against the currently active profile, preservingoknodes and cumulative usage.
Worked example
{
"steps": [
{ "id": "recon", "role": "scout",
"objective": "Map how auth middleware is wired.",
"deliverable": "File list with line evidence." },
{ "id": "patch", "role": "worker",
"objective": "Add a rate-limit guard to the login route.",
"deliverable": "Guard implemented and npm test green.",
"needs": ["recon"], "touches": ["src/auth/**"] },
{ "id": "review", "role": "reviewer",
"objective": "Review the guard for bypasses.",
"deliverable": "Findings with line references.",
"needs": ["patch"] }
]
}
What happens: recon runs first as a scout child — the cheapest role, read-only. patch waits on it because needs says so, and runs alone as the only write step. review waits on patch and runs read-only against the result. Each child returns a sentinel; each child's output and session land in the run directory. The default roster pins no models, so each row shows inherit until you choose per-role models.
Tree rows render as <glyph> <role> <model>:<effort> — <task> <badges>, followed by one id [role] status detail line per node:
pipeline completed (run-ldt8gk-2fz)
✔ scout inherit:low — Map how auth middleware is wired.
✔ worker inherit:low — Add a rate-limit guard to the login route.
✔ reviewer inherit:max — Review the guard for bypasses.
- recon [scout] ok
- patch [worker] ok
- review [reviewer] ok
run directory: ~/.pi/agent/pipeline/runs/run-ldt8gk-2fz
A child with spawn rights can nest: if patch spawned its own scout step, the tree grows a branch under it and the run keeps one directory and one cumulative usage total.
✔ scout inherit:low — Map how auth middleware is wired.
✔ worker inherit:low — Add a rate-limit guard to the login route. (+1)
└─ scout inherit:low — Check the route registration order.
Profiles
Profiles resolve across three scopes. The higher scope wins a name collision.
| Scope | Path |
|---|---|
| Project (needs trust, wins) | <project>/.pi/pipeline/profiles/*.json |
| User | ~/.pi/agent/profiles/pipeline/*.json |
| Package | <package>/profiles/*.json |
Long prompts live next to the profile as <role>.md and are referenced with promptFile; inherited roles keep the prompt from the layer that defined them.
{
"schemaVersion": 1,
"name": "my-roster",
"description": "What this roster is for.",
"extends": "default",
"defaults": { "provider": "opencode-go", "model": "deepseek-v4.1-flash", "thinking": "low" },
"parent": { "model": "opencode-go/deepseek-v4.1-flash", "thinking": "low" },
"limits": { "maxThinking": "max", "maxDepth": 2, "maxNodes": 12, "maxConcurrent": 3,
"maxEscalations": 3, "perStepTimeoutMs": 300000, "maxResultChars": 8000,
"rosterInjectTokens": 600, "allowUnavailableModels": false,
"budget": { "tokens": 400000, "usd": 3 } },
"runs": { "keep": 20, "maxAgeDays": 7, "pinUnfinished": true },
"modelScope": { "enforce": false, "allow": ["opencode-go/*"] },
"aggressiveness": "medium",
"roles": {
"worker": {
"description": "Implements one validated change and validates it.",
"promptFile": "worker.md",
"model": "opencode-go/deepseek-v4.1-flash",
"thinking": "low",
"access": "write",
"tools": ["read", "grep", "find", "ls", "bash", "edit", "write"],
"context": { "projectFiles": true, "skills": false },
"verify": { "command": "npm test", "expectExit": 0, "timeoutMs": 180000 },
"escalate": { "to": "opencode-go/qwen3.8-max", "thinking": "xhigh", "crossFamily": true, "maxAttempts": 1 },
"canSpawn": ["reviewer"]
}
}
}
Profile fields: schemaVersion, name, description, extends, defaults (provider, model, thinking, tools, context), parent (model, thinking), limits, runs, modelScope (enforce, allow), aggressiveness, roles.
Role fields: description (required), prompt, promptFile, model, thinking (a level, or false to disable), provider, access, tools, context (projectFiles, skills), verify (command, expectExit, timeoutMs), canSpawn, escalate (to, thinking, crossFamily, maxAttempts), enabled.
Defaults, from src/profiles.ts:
| Field | Default |
|---|---|
limits.maxThinking |
max |
limits.maxNodes / maxDepth / maxConcurrent / maxEscalations |
12 / 2 / 3 / 3 |
limits.perStepTimeoutMs |
300000 |
limits.maxResultChars |
8000 |
limits.rosterInjectTokens |
600 |
limits.allowUnavailableModels |
false |
limits.budget |
none (the shipped default profile sets 400000 tokens / $3) |
runs.keep / maxAgeDays / pinUnfinished |
20 / 7 / true |
verify.timeoutMs |
120000 |
escalate.maxAttempts |
1 (allowed 1..3) |
role tools |
read, grep, find, ls, bash |
role context.projectFiles / skills |
true / false |
aggressiveness (low | medium | high | off, default medium) sets how hard the injected guidance pushes the main session toward the pipeline. low delegates only when inline work genuinely cannot do the job, medium delegates where it applies, high makes a pipeline the default for any substantial task, and off suppresses the guidance entirely. Precedence is session override → profile → persisted default → medium.
- A profile with zero roles is valid — nothing can run, and
pipelinesays so instead of inventing roles. accessis derived fromtools:editorwritepresent meanswrite, otherwiseread.access: "read"withedit/writetools is an error.canSpawn: []is an explicit leaf. OmittingcanSpawnallows any role up tolimits.maxDepth.parentsets the default model and effort for the main session while the profile is active. It is applied on a fresh session (startup,new,reload) and immediately when you switch profile, but not onresumeorfork, where the session keeps the model it recorded. Omitmodelto pin only the effort.- Precedence for a role's model: step override → profile role → profile
defaults→extendschain → the parent session model.provider/id,provider:id, bare ids,inheritand a:thinkingsuffix all resolve. - The shipped
defaultprofile pins no models. A model belongs to the user's credentials, not the roster, so every role inherits the session model until you choose. Until a model is pinned,/pipeline-doctorand session start say so; the inherited model always works because the session already has credentials for it. /pipeline-initreads the accessible registry, suggests a cheapest, a middle and a most-expensive model as the cheap/mid/strong tiers, and lets you confirm or replace each pick. It writes~/.pi/agent/profiles/pipeline/<name>.jsonthatextendsthe active roster with only the chosenmodelper role (recon → cheap, review/oracle/planner → strong, the rest → mid, plus a strong-model escalation on write roles), validates it as discovery will see it, and activates it. The non-interactive form is/pipeline-init <name> <cheap> <mid> <strong>withprovider/idrefs.- Activation fails closed: unresolved models, unauthenticated models,
maxThinkingbreaches,modelScopebreaches, unknowncanSpawntargets, self-escalation, an escalation that cannot change model or effort, andaccess/toolscontradictions are all reported by name as errors, and nothing runs until they are fixed. Unknown keys are reported as warnings, not errors.
Switching profiles: /pipeline-profile (picker), /pipeline-profile <name>, /pipeline-profile none, pipeline({ profile }) for one run, or pi --pipeline-profile <name>. The active profile is a plugin-owned pointer in config.json (the project file <project>/.pi/pipeline/config.json wins over ~/.pi/agent/extensions/pipeline/config.json when the project is trusted), so a switch is instant and reversible. Switching is refused while a run is in flight: a run snapshots its roster.
Running work
pipeline({ steps }). Each step needs id, role, objective, deliverable; optional are scope, context, needs, model, thinking, touches. The tool also takes profile (run on a named profile for this call), concurrency (max parallel read steps, clamped to maxConcurrent) and resume (a recorded run id).
- Ordering comes from
needs, not array position. Independent read steps run in parallel; a write step always runs alone, and a reader that could observe a partial write is promoted after that writer (recorded, with the basis: declaredtouches, inferred, or conservative). - Every child ends with
PIPELINE_STATUS: okorPIPELINE_STATUS: blocked - <reason>. - Escalation fires only for a step that ends
blocked(explicit blocked, or a failedverify.command) or that produced no output at all (recordedfailed). A timeout returnsstopped; a thrown error is recordedfailedin the catch block before the escalation check. Neither escalates. A missing sentinel with output is treated asok. - Escalation is a typed, validated handoff: the target is checked at activation, self-escalation is rejected, attempts and total escalations are capped, and the path is recorded on the node as
source → targetwith a reason. - A
verify.commandruns in the workspace after the step and its exit code is the verdict; a failure marks the step blocked, which is exactly the escalation trigger. - Changed files come from the child's own
write/edittool events, not from what the child claims. Writes outside the workspace are recorded separately. - The model-facing result is capped at
maxResultCharsand always names the run directory, so the full detail stays one read away.
Runs, retention, resume
Each run writes ~/.pi/agent/pipeline/runs/<runId>/:
| File | Contents |
|---|---|
run.json |
nodes, rounds[], cumulative usage |
node-<id>.output.md |
each node's full output |
<session>.jsonl |
the child's own session transcript |
events.jsonl |
node start/end, escalation and error events |
Retention: finished runs age out by maxAgeDays, then by keep; unfinished runs are pinned and dropped only as a last resort, and every such drop is reported at session start.
pipeline({ resume: runId }) or /pipeline-resume <runId> re-runs every non-ok node as a new round against the currently active profile, preserving ok nodes and cumulative usage. A role that has left the roster is named as a profile error. Each node records its profile and round, so rounds of a resumed run are visibly not comparable when the profile changed.
UI and commands
- Inline tree under the
pipelinetool call, live while children stream. - One-line widget under the editor:
▸ pipeline profile: default · 1 running · 2/3 ok · 12400 tok— the raw token count, not abbreviated. /pipeline-tree, or the configured shortcut (settingpipeline.treeShortcut, defaultctrl+shift+p), opens the overlay:↑↓/jkselect,Enterdetail (status, verification, artifacts, output and transcript paths),xstop the selected node,Escclose.
| Command | Purpose |
|---|---|
/pipeline-aggressiveness [low|medium|high|off] |
How readily the parent delegates (this session); default <level> persists it, status reports it |
/pipeline-tree |
The live tree (non-TUI modes print it) |
/pipeline-profile [name|none|list|check|generate] |
List, switch, validate, or generate a roster from the registry (generate <provider> [role,role,...], metadata only, no probes) |
/pipeline-init [name] [cheap] [mid] [strong] |
Pick per-role models from the accessible registry (cheap/mid/strong tiers suggested by cost), write them as a user profile that extends the active roster, and activate it |
/pipeline-stop [nodeId] |
Stop a node, or the whole run |
/pipeline-resume <runId> |
Continue a recorded run |
/pipeline-runs |
List recorded runs |
/pipeline-doctor |
Active profile and source, every role's resolved model and credential state, limits, run-dir size, resumable count, delegation-tool collisions |
/pipeline-smoke |
One real read-only step, to verify children work (needs PIPELINE_SMOKE=1; spends tokens) |
pipeline_status({action: "roster" | "tree" | "runs" | "stop", runId?, nodeId?}) exposes the same surface to the model, and is always authoritative for the roster. /pipeline-doctor reports the run directory as <runs> on disk, <KiB> KiB · <n> resumable.
Checking it
npm install # devDependencies: typescript and @types/node
npm run typecheck # strict, no output means clean
npm run check # node --experimental-strip-types scripts/selfcheck.mjs
PIPELINE_SMOKE=1 pi --extension ./src/index.ts # then /pipeline-smoke
selfcheck.mjs needs no dependencies: the pure modules (profiles.ts, plan.ts, runs.ts) have no platform imports, so profile discovery and validation, ordering, reader/writer promotion, sentinel parsing, budget and escalation decisions, retention and the tree renderer are exercised directly.
Trust
Profile files carry system prompts, tool allowlists and optional verify.command values, so project-scoped profiles are only read after project trust, and children inherit the parent's trust decision without prompting. A user profile may not extend a project profile. Escalation never widens a role's tool allowlist. Children run with Pi's normal (unsandboxed) OS permissions.
Known limitations
- In-process children only. Detached/background runs need an
@earendil-works/pi-agent-core/nodeexport this Pi install does not provide; a run therefore lives and dies with its session. - Role prompts are appended to the child's system prompt (so children keep Pi's built-in tool guidance) rather than replacing it.
- Model resolution is implemented on
ctx.modelRegistryrather thanresolveCliModel, because the extension context exposes aModelRegistry, not aModelRuntime. - No steering of a live child (stop only), no worktree isolation, no external CLI runners, no live provider probes.
Publishing
npm publish --access public
The pi-package keyword is what makes it eligible for the Pi package gallery, and pi.extensions / pi.skills declare what ships. assets/icon-master.png is the card image referenced by pi.image, so it has to be committed and pushed before the gallery can render it.