No description
  • TypeScript 99.5%
  • Nix 0.4%
  • Shell 0.1%
Find a file
hayshin f132d65729
All checks were successful
CI / Check, test, and validate package (push) Successful in 37s
feat(theme): add optional pi-theme integration
- 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
2026-09-21 14:58:00 +05:00
.forgejo/workflows chore(tooling): adopt extension template 2026-09-10 19:04:39 +05:00
herdr-plugin refactor: rename project to pi-subagents 2026-09-10 22:42:03 +05:00
skills feat(skills): add change reviewer role 2026-09-12 15:00:36 +05:00
src feat(theme): add optional pi-theme integration 2026-09-21 14:58:00 +05:00
test feat(theme): add optional pi-theme integration 2026-09-21 14:58:00 +05:00
.gitignore chore(tooling): adopt extension template 2026-09-10 19:04:39 +05:00
devenv.lock chore(tooling): adopt extension template 2026-09-10 19:04:39 +05:00
devenv.nix chore(tooling): adopt extension template 2026-09-10 19:04:39 +05:00
devenv.yaml chore(tooling): adopt extension template 2026-09-10 19:04:39 +05:00
index.ts feat(theme): add optional pi-theme integration 2026-09-21 14:58:00 +05:00
LICENSE docs(license): set hayshin as main owner with upstream credit 2026-09-11 12:43:41 +05:00
package.json chore: remove docs directory and its references 2026-09-11 13:20:57 +05:00
pnpm-lock.yaml chore(tooling): adopt extension template 2026-09-10 19:04:39 +05:00
pnpm-workspace.yaml chore(tooling): adopt extension template 2026-09-10 19:04:39 +05:00
README.md feat(theme): add optional pi-theme integration 2026-09-21 14:58:00 +05:00
tsconfig.json chore(tooling): adopt extension template 2026-09-10 19:04:39 +05:00
vite.config.ts chore(tooling): adopt extension template 2026-09-10 19:04:39 +05:00

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 which execs 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.subscribepane.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, and HERDR_SOCKET_PATH into 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: claude produce 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 packageManager field. 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-subagents BEFORE pi-interactive-subagents in packages.

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-codebase child has the same tools as an implementing-changes child 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 --model used to produce a silent zombie).
  • No stall-status machinery. The starting/active/waiting/stalled state 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>.sh reproduces the exact child environment and invocation.
  • Every failure steer carries the child session path; pi --session <path> resumes it, or use subagent_message with the child's name to continue a finished session.

Known limitations / upstream notes

  • Exit-code sidecar: herdr's pane.exited event carries no exit code and pane records vanish on exit, so the wrapper script writes <session>.exitcode. If herdr adds exit_code to pane.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 herdr CLI except for exact split-ratio writes; those and events.subscribe use the raw socket because the CLI does not expose layout.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.