- TypeScript 99.5%
- Nix 0.4%
- Shell 0.1%
|
All checks were successful
CI / Check, test, and validate package (push) Successful in 37s
- publish a lifecycle-safe display bridge for themed messages and widgets - preserve standalone renderers as guarded fallbacks - document and test load-order, reload, and invalid API handling |
||
|---|---|---|
| .forgejo/workflows | ||
| herdr-plugin | ||
| skills | ||
| src | ||
| test | ||
| .gitignore | ||
| devenv.lock | ||
| devenv.nix | ||
| devenv.yaml | ||
| index.ts | ||
| LICENSE | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| tsconfig.json | ||
| vite.config.ts | ||
pi-subagents
Interactive subagent orchestration for pi, built natively on herdr — spawn and message 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.
Demo
https://github.com/user-attachments/assets/a016390a-4d6a-4a74-ad72-293f0d1b5c2f
Origins & credit
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, message-by-name/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.
Why a separate herdr-native extension
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.
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-backed plugin pane launch (
herdr plugin pane open … --entrypoint subagent --env PI_HERDR_LAUNCH_SCRIPT=<script>): Herdr starts a fixed dispatcher whichexecs the generated launch script. No interactive shell, typing, or 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.
Requirements
- herdr ≥ 0.8.2, the first known-good release with split plugin panes. The extension checks the running version and plugin state at session start.
- 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.js
^20.19.0,^22.18.0, or>=24.11.0(matching the package engine metadata). - pnpm, pinned via the
packageManagerfield. Nix users can use the bundled devenv shell.
Setup
Pi packages execute with the user's full system permissions. Review this extension and its bundled Herdr dispatcher before installing it.
Install as a pi package (add to ~/.pi/agent/settings.json):
{
"packages": [
"/path/to/pi-subagents", // local checkout; or a git: URL once published
],
}
Link and enable the bundled Herdr plugin from the same checkout:
herdr plugin link /path/to/pi-subagents/herdr-plugin --enabled
herdr plugin enable pi-subagents
The manifest and dispatcher are versioned with the pi extension. The dispatcher is static; each spawn selects its generated launch script through a pane-local environment variable.
Then start herdr in your terminal and run pi in a pane:
herdr # opens the multiplexer
# in a pane:
pi
The subagent, subagent_message, and subagents_list tools appear
automatically. The agent delegates tasks to subagents on its own — no slash commands are
required or provided. After delegating, wait for the result or do only independent work.
Generic argv pane contract
The bundled Herdr plugin also provides a generic launcher for non-subagent consumers. Its contract is:
- plugin id:
pi-subagents - entrypoint:
argv - launch-script env var:
PI_HERDR_LAUNCH_SCRIPT
Point the env var at an absolute, readable script. For example, a launch script for another TUI could contain:
#!/usr/bin/env bash
trap '' TSTP
exec /absolute/path/to/hunk
Open it in a pane with:
herdr plugin pane open \
--plugin pi-subagents \
--entrypoint argv \
--placement split \
--target-pane "$HERDR_PANE_ID" \
--direction right \
--cwd "$PWD" \
--env "PI_HERDR_LAUNCH_SCRIPT=/absolute/path/to/launch-hunk.sh" \
--no-focus
The dispatcher runs non-interactive, non-login bash. Use absolute paths for binaries; shell rc files and direnv are not loaded unless the launch script does that work itself. That clean startup is intentional: it avoids typing a command into a pane whose interactive shell may still be running direnv initialization.
Launch scripts should also trap '' TSTP. An argv-launched pane has no parent interactive shell
from which to run fg, so Ctrl+Z would otherwise suspend the command and wedge the pane
permanently.
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.
Transition: running side-by-side with pi-interactive-subagents
Tool names are identical by design: agent-def spawning: false / deny-tools frontmatter
gates the exact names subagent, subagent_message, 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-subagentsBEFOREpi-interactive-subagentsinpackages.
Behavior matrix during the transition:
| pi is running… | active provider |
|---|---|
| inside a herdr pane | pi-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.
Configuration
Child model override
By default, child processes use pi's normal startup model resolution. To force all newly spawned and resumed subagents to use a specific model, pin one with:
/subagents model # show effective model
/subagents model <provider>/<model> # pin a model (patterns and :thinking suffixes allowed)
/subagents model reset # return to pi default resolution
This writes ~/.pi/agent/extensions/subagents.json:
{
"model": "anthropic/claude-sonnet-4-5"
}
The value is passed directly to pi's --model option, so model patterns and an optional thinking
suffix (for example, sonnet:high) are supported. If PI_CODING_AGENT_DIR is set, the file is
read from $PI_CODING_AGENT_DIR/extensions/subagents.json instead. The file is read on every
spawn/resume; no /reload is needed. Missing settings preserve pi's default model behavior, while
invalid JSON or an invalid model value produces a clear launch error.
Optional pi-theme styling
If the pi-theme extension (@hayshin/pi-theme: Claude Code visual identity for pi) is installed, the
subagent_result / subagent_ping message renderers and the running-subagents widget delegate
their styling to it at render time; otherwise the built-in standalone rendering is used. This is
entirely optional — there is no package dependency on pi-theme, and every delegation is guarded:
a missing or version-mismatched theme API, a missing method, a throw, or an invalid return value
falls back to the built-in output. Discovery uses global markers (pi-theme reads
Symbol.for('pi-subagents.display.provider.v1') to find this extension, and this extension reads
Symbol.for('pi-theme.subagents.display.api.v1') only when rendering).
Environment variables
Environment variables may be 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. |
direnv / devenv / varlock repos
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.
Tools & commands
| Tool | Description |
|---|---|
subagent |
Spawn a sub-agent in a dedicated herdr pane (async — returns immediately) |
subagent_message |
Send a message to a subagent by name: steers it if running (ack-only, no new result) or resumes its finished session to continue it (result steered back) |
subagents_list |
List currently running subagents |
A message_orchestrator child extension is loaded into every child. An untouched child
pane auto-exits after a clean turn and sends its result back automatically. If a human submits
a non-empty prompt in the child pane, that pane is permanently taken over manually and stays
open until /exit, Ctrl-D, or pane close. Programmatic subagent_message follow-ups do not
count as human takeover. A child can also ask via message_orchestrator and stay open until
the orchestrator replies, and a child that spawned its own subagents keeps running until those
children settle.
Role skills
Every subagent is identical by default — no agent defs or per-agent model/tool lists. A single
global child model override may be configured as described above. Role specialization is delivered
as skills, passed at spawn via the skills param
(and forwarded to the child as pi's repeatable --skill <path> startup flags):
subagent({ name, task, skills: ["implementing-changes"] }). Bundled role names resolve to their
skills/<name>/SKILL.md file; custom skills may be supplied as paths.
This repo vendors five skills under skills/:
| Skill | Role |
|---|---|
orchestrating-subagents |
Orchestrator-side playbook — when to delegate, decomposition, role selection, parallelism, follow-ups, synthesis. Read by the main agent before spawning |
implementing-changes |
Code editing — targeted edits, verification, structured change summary |
reviewing-changes |
Read-only change review — diff checked against the brief, evidence-based verdict with blocking vs non-blocking findings |
scouting-codebase |
Read-only codebase recon — structured map of files, code, architecture |
researching-web |
Web research — structured, cited brief |
orchestrating-subagents is the only skill the parent reads; the rest are passed to children via skills.
They ship with the extension — the package manifest declares "skills": ["./skills"], so pi
loads them automatically whenever the package is loaded (and into every child, since children run
the same pi setup). No install step. Disable individually with pi config or the settings
package-filter form ("skills": [] / -path entries) if you don't want all of them.
Notes:
- Role constraints are prompt-level, not enforced — a
scouting-codebasechild has the same tools as animplementing-changeschild and is only asked to stay read-only. - Children are flat: a spawned subagent cannot spawn its own (the spawning tools are denied to nested subagents).
Lifecycle: every child ends in exactly one honest state
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 ran to completion without human takeover | completed + summary (last assistant message); pane auto-closes |
| human took over the pane and later closed it | completed-user-exit/pane outcome + last message |
child called message_orchestrator (session stays open) |
subagent_ping + the child's question + session path (non-terminal; the child keeps running until it completes) |
child exited nonzero within the startup window (e.g. bad --model) |
failed to launch (exit code N) + captured pane tail + pane id + launch script path; pane held open for post-mortem |
| child crashed later | exit code + captured pane tail + 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" |
Differences vs pi-interactive-subagents
- No launch race, by construction. An argv-backed plugin dispatcher 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 a completion signal is reported as exactly that, not as a generic completion.
- pi children only. No Claude Code CLI path, no transcript-copy machinery.
- Implicit auto-exit. Untouched children close after a clean turn; human input permanently switches a pane to manual mode.
- herdr only. No cmux/tmux/zellij/wezterm code paths.
Debugging
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 (the minimal subagent prompt)
├── 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 (via subagent_message)
In the session dir root sits subagent-name-registry.json, mapping each spawned sub-agent's
display name to its session file so subagent_message can resume a finished child by name.
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_messagewith the child's name to continue a finished session.
Known limitations / upstream notes
- Exit-code sidecar: herdr's
pane.exitedevent carries no exit code and pane records vanish on exit, so the wrapper script writes<session>.exitcode. If herdr addsexit_codetopane.exited, 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 beside the orchestrator pane
(
--target-pane $HERDR_PANE_ID --direction right). After parallel spawns, their split ratios are adjusted so every running child has the same width; multi-workspace layouts are unexplored. - Hybrid client: request/response goes through the
herdrCLI except for exact split-ratio writes; those andevents.subscribeuse the raw socket because the CLI does not exposelayout.set_split_ratio.
Development
Enter the reproducible Nix environment and install the pinned dependency graph:
devenv shell
pnpm install
Vite+ provides formatting, linting, strict TypeScript checking, and the Vitest unit runner:
pnpm run check
pnpm run lint
pnpm run fmt
pnpm run test
pnpm run test:watch
pnpm run test:coverage
pnpm run pack:check
Coverage reports are written to coverage/. Ordinary tests and CI use mocked Herdr boundaries
and do not launch tmux, Herdr, or a model. The serialized integration suite is deliberately a
separate command:
pnpm run test:integration
The integration harness creates its own tmux session and named Herdr session
(subagents-test-<pid>-…), gives both Herdr and Pi private temporary config roots, links
and enables the plugin only there, and refuses to run against the default Herdr socket. It needs
Herdr, tmux, Pi, and model authentication, and skips cleanly when prerequisites are unavailable;
your live sessions and global Herdr/Pi config are never touched.
Pi loads the TypeScript source directly, so there is no compiled build. The package allowlist
ships the root index.ts entry point plus src/, skills/, herdr-plugin/, the README,
and the license. Inspect the exact tarball contents before publishing:
pnpm run pack:check
pnpm publish
Consumers can then install it with pi install npm:@hayshin/pi-subagents; they must still link and
enable the bundled Herdr plugin as described in Setup.
License
MIT. Portions (agent-def parsing, session seeding, steer formats, child extension)
ported from pi-interactive-subagents (MIT,
HazAT); the Herdr CLI envelope-parsing pattern is adapted from
pi-herdr (MIT). Full attribution is preserved in
LICENSE.