Interactive subagent orchestration for pi, built natively on herdr — spawn, resume, and interrupt subagents in herdr panes with truthful lifecycle events. Fully non-blocking: the orchestrator keeps working while subagents run; results are steered back as async messages that wake it up.
pi-herdr-subagents-demo.mp4
This extension is a herdr-native descendant of pi-interactive-subagents by HazAT. I used that extension daily for months and it completely changed how I structure my agent work. The orchestration model here is its design — spawn subagents into visible terminal surfaces, keep working, get woken by steer messages when they finish, resume/interrupt/list, markdown agent definitions — and several modules are direct ports (see License). I've also contributed improvements upstream. If you work in tmux, cmux, zellij, or wezterm — or need Claude Code children — use pi-interactive-subagents; it's excellent.
pi-interactive-subagents drives its four muxes through one generic backend mechanism: create a
pane, wait for its interactive shell, type a launch command into it, verify startup with
retries, and poll the screen for a completion sentinel. Its hardest reliability edge cases
trace back to that open-loop mechanism — a shell still running direnv/devenv init can swallow
the typed command, and a dead child looks like a screen that stopped changing (the war story is
in docs/PROJECT-BRIEF.md).
To be fair: that mechanism is an implementation choice more than a hard limit of the muxes. tmux, zellij, and wezterm can all launch a command directly at pane creation, and sidecar files can replace screen scraping — pi-interactive-subagents could plausibly be retrofitted with per-backend launch and lifecycle adapters (cmux's currently exposed CLI is the awkward one, and push-style lifecycle events aren't uniformly available). But that retrofit is a substantial refactor multiplied across every backend and both of its launch paths. Rather than carry that surface area, this extension targets herdr only and uses its native primitives directly:
- argv process launch (
herdr agent start … -- bash <script>): the child is started as a direct process. No shell, no typing, no delays — the launch race cannot happen, by construction. There is no verify/retry machinery because there is nothing to verify. - Socket events (
events.subscribe→pane.exited/pane.closed): a child that dies is an observable event within milliseconds, not a screen that stopped changing.
- herdr ≥ 0.7.1 (protocol 14 — pinned; the extension pings the socket at session start and warns if the server is unreachable)
- pi running inside a herdr pane. herdr injects
HERDR_ENV,HERDR_PANE_ID, andHERDR_SOCKET_PATHinto every pane; the extension activates only when they are present. - pi children only. Claude Code / codex subagents are an explicit non-goal — if you need
them, use pi-interactive-subagents.
Agent defs with
cli: claudeproduce a clear "unsupported" error. - node ≥ 22.
Install as a pi package (add to ~/.pi/agent/settings.json):
Then start herdr in your terminal and run pi in a pane:
herdr # opens the multiplexer
# in a pane:
piThe subagent, subagent_resume, subagent_interrupt, and subagents_list tools plus the
/subagent, /subagents-init, and /iterate commands appear automatically.
To install the package's four example agent definitions (worker, planner, scout, and
reviewer) as user-owned files, run:
/subagents-init # global: ~/.pi/agent/agents/
/subagents-init project # project-local: .pi/agents/
The command creates the target directory and copies only missing files. Existing files and symlinks are skipped, so customized definitions are never overwritten. The copies are not tied to the package after installation: later package updates will not replace them; run the command again only to install template filenames that are still missing.
Outside herdr the extension registers nothing at load. At session_start, if no other
extension provides a subagent tool, it registers setup-hint stubs that explain how to run pi
inside herdr (so the model gets a clear answer instead of a missing tool). If another extension
already provides subagent (e.g. pi-interactive-subagents in tmux), it registers nothing and
the other extension wins cleanly.
Tool names are identical by design: agent-def spawning: false / deny-tools frontmatter
gates the exact names subagent, subagent_resume, subagent_interrupt, subagents_list,
and existing orchestrator prompts reference those names in prose. pi resolves duplicate tool
names first-loaded-extension-wins, silently — so ordering matters:
List
pi-herdr-subagentsBEFOREpi-interactive-subagentsinpackages.
Behavior matrix during the transition:
| pi is running… | active provider |
|---|---|
| inside a herdr pane | pi-herdr-subagents (registers at load, wins the race) |
| inside tmux/cmux/zellij/wezterm | pi-interactive-subagents (this extension stays silent) |
| outside any mux | whichever is loaded; ours only adds setup-hint stubs if nothing else provides subagent |
If this extension is inside herdr but lost the registry race (loaded after another subagent
provider), it emits a visible session_start warning telling you to fix the package order —
it never fails silently.
No relation to pi-herdr (the generic
user-facing pane tool): no dependency in either direction, no name collisions (herdr vs
subagent*); they coexist fine.
All configuration is via environment variables (set globally, or per-project via .envrc):
| Variable | Default | Effect |
|---|---|---|
PI_HERDR_LAUNCH_PREFIX |
(unset) | Template for the command wrapping the child pi invocation; {cwd} is interpolated shell-escaped. If defined (even empty) it replaces direnv autodetection; empty string disables wrapping entirely. Examples: direnv exec {cwd} (the autodetect default), mise exec --, nix develop {cwd} -c. |
PI_HERDR_PI_BIN |
first executable pi on PATH |
Absolute path of the pi binary to launch children with (e.g. ~/.local/bin/pi). |
PI_HERDR_DIRENV |
(unset) | Set to 0 to disable the direnv exec autodetect (see below). |
PI_HERDR_HOLD_OPEN_SECS |
15 |
Startup-crash window: if the child exits nonzero within this many seconds, the pane is held open for post-mortem (0 disables). |
HERDR_BIN |
herdr on PATH |
herdr binary override. |
Spawning into a repo whose environment lives behind direnv (devenv, nix, varlock-managed
secrets) is a first-class case — it is exactly where typed-launch muxes fail. The generated
launch script exports the orchestrator's PATH plus curated PI_SUBAGENT_* vars (never a
full env dump), and when the child's effective cwd (or an ancestor, up to $HOME) has an
.envrc, the pi invocation is wrapped in direnv exec '<cwd>'. That wrap materializes the
devenv environment — node, pnpm, postgres, project env vars — before pi starts. If your pi
is itself a wrapper that needs in-env tools (e.g. varlock for 1Password-backed secrets), it
runs inside that environment and just works; the extension needs zero knowledge of it. The
whole chain — launch script → direnv exec → devenv PATH → pi wrapper → varlock — is covered
by an integration test against a real devenv checkout (test/integration/direnv-env.test.ts).
Set PI_HERDR_DIRENV=0 or an explicit PI_HERDR_LAUNCH_PREFIX to override.
| Tool | Description |
|---|---|
subagent |
Spawn a sub-agent in a dedicated herdr pane (async — returns immediately) |
subagent_resume |
Resume a previous sub-agent session in a new pane (async) |
subagent_interrupt |
Send Escape to a running subagent's active turn |
subagents_list |
List available agent definitions (project-local .pi/agents/ overrides global) |
| Command | Description |
|---|---|
/subagent <agent> [task] |
Spawn a named agent directly |
/subagents-init [global|project] |
Copy missing example agent definitions into user-owned config |
/iterate [task] |
Fork the current session into a subagent for focused work |
Agent definitions in project-local .pi/agents/*.md or global ~/.pi/agent/agents/*.md are read
with the same frontmatter semantics as pi-interactive-subagents (name, description, tools,
deny-tools, model, thinking, spawning, auto-exit, interactive, session-mode, systemPromptMode,
…) — the same defs drive both extensions during the transition. The package templates are not a
runtime fallback; /subagents-init explicitly copies them into one of these user-owned
locations. A subagent_done / caller_ping child extension is loaded into every child for the
completion handshake.
The watcher classifies each child from socket events + sidecar files. There is no path to an eternal "stalled" zombie — every row below terminates the running entry with a steer message:
| What happened | Steer you get |
|---|---|
child called subagent_done |
completed + summary (last assistant message) |
child called caller_ping |
subagent_ping + the child's question + session path |
user drove the child and quit pi without subagent_done |
distinct honest phrasing: "closed by user, no subagent_done" + last message + session path |
child exited nonzero within the startup window (e.g. bad --model) |
failed to launch (exit code N) + pane id + launch script path; pane held open for post-mortem |
| child crashed later | exit code + last message + session path (resumable) |
| pane killed externally (no sidecars) | honest failure steer + session path (resumable) |
| pane vanished while the event stream was down | classified from on-disk sidecars, else "ended while event stream was down" |
- No launch race, by construction. argv launch replaces type-into-shell; the shell-ready delay, launch verify/retry loop, and sentinel screen polling have no analog here.
- Truthful crash/lifecycle steers.
pane.exited+ exit-code sidecar give immediate, honest launch-failure and crash reporting (a bad--modelused to produce a silent zombie). - No stall-status machinery. The
starting/active/waiting/stalledstate machine and activity files are gone — herdr's sidebar shows semantic per-pane agent state, and dead children can no longer masquerade as "stalled". The status widget is a slim name/agent/elapsed/count list. - Distinct user-exit phrasing. A user quitting a child without
subagent_doneis reported as exactly that, not as a generic completion. - pi children only. No Claude Code CLI path, no transcript-copy machinery.
- Panes auto-close on clean exit (held open only for startup crashes).
- herdr only. No cmux/tmux/zellij/wezterm code paths.
Artifacts live under the orchestrator's session dir, same convention as
pi-interactive-subagents — <sessionDir>/artifacts/<session-id>/:
artifacts/<session-id>/
├── context/<name>-<ts>.md # task handoff file (child's initial @-message)
├── context/<name>-sysprompt-<ts>.md # system prompt file (when the agent def provides one)
├── subagent-scripts/<name>-<id>.sh # the generated launch script (the single source of truth
│ # for env exports, direnv wrap, pi argv)
└── subagent-resume/<name>-<ts>.md # resume follow-up messages
Next to each child session file (…/sessions/<encoded-cwd>/<ts>_<id>.jsonl):
<session>.exit— semantic completion sidecar written by the child ({"type":"done"}or{"type":"ping",…})<session>.exitcode— process exit code written by the wrapper script
Useful tricks:
- Startup crashes hold the pane open (default 15s window) with the error visible — read it, then press Enter in the pane to close.
- Re-run any launch by hand:
bash <artifacts>/subagent-scripts/<name>-<id>.shreproduces the exact child environment and invocation. - Every failure steer carries the child session path;
pi --session <path>resumes it, or usesubagent_resume.
- Exit-code sidecar: herdr's
pane.exitedevent carries no exit code and pane records vanish on exit, so the wrapper script writes<session>.exitcode. A herdr feature request forexit_codeonpane.exitedis drafted (not yet filed) indocs/full-socket-client.md— once it lands, the sidecar can be deleted. - No stall detection (yet): a child that is alive but spinning its wheels is not flagged;
herdr's sidebar agent states are the current signal. Could be reintroduced on
pane.agent_status_changed. - Single-workspace topology: children split into the orchestrator's tab
(
--tab $HERDR_TAB_ID --split right); multi-workspace layouts are unexplored. - Hybrid client: request/response goes through the
herdrCLI; onlyevents.subscribeuses a raw socket connection. The all-socket design (and when to switch) is sketched indocs/full-socket-client.md.
npm test # unit suite (mocked herdr boundary), ~0.6s
npm run test:integration # real pi children in an isolated named herdr session
# (needs herdr + tmux + model auth; skips cleanly otherwise)The integration harness creates its own tmux session and named herdr session
(herdr-subagents-test-<pid>-…), points PI_CODING_AGENT_DIR at a temp dir, and refuses to
run against the default herdr socket — your live sessions and global pi config are never
touched.
MIT. Portions (agent-def parsing, session seeding, steer formats, child extension) ported from pi-interactive-subagents (MIT, HazAT); herdr CLI envelope-parsing pattern adapted from pi-herdr (MIT).
{ "packages": [ "/path/to/pi-herdr-subagents" // local checkout; or a git: URL once published ] }