pi-legion
Pi Legion v2: recursive centurion trees on headless Pi RPC; shared SQLite WAL legion.db. Four-stage planning/implementing/reviewing/verifying state machine, parent/child/sibling legion_mail, three-layer /legion UI, upward MR trees, squad resume.
Package details
Install pi-legion from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-legion- Package
pi-legion- Version
3.0.1- Published
- Oct 18, 2026
- Downloads
- 361/mo · 361/wk
- Author
- zzjcool
- License
- MIT
- Types
- extension, skill
- Size
- 1.4 MB
- Dependencies
- 0 dependencies · 5 peers
Pi manifest JSON
{
"skills": [
"./skills"
],
"extensions": [
"./index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
name: pi-legion npm: "pi-legion" description: "Pi Legion v2: recursive centurion trees on headless Pi RPC; shared SQLite WAL legion.db. Four-stage planning/implementing/reviewing/verifying state machine, parent/child/sibling legion_mail, three-layer /legion UI, upward MR trees, squad resume."
pi-legion
A Pi extension for building recursive, resumable agent teams. A centurion
leads each squad; Pi RPC runs its children as headless processes, while a
shared SQLite ledger records the whole tree.
Name note. This package was formerly published as
@zzjcool/pi-herdr-subagents(through 0.14.3; renamed after an npm name collision), then aspi-herdr-swarm(0.15.0), before becomingpi-legionin 0.16.0. 2.0.0: the legion era — recursive centurion tree on pi RPC transport; herdr pane execution archived to legacyWhat changed in 2.x? See CHANGELOG.md (Keep a Changelog). Published notes also land on GitHub Releases.
Contents
- Architecture
- Spawn mode
- Headless child contract
- Quick start
- Legion tools and phases
- Three-layer visualization
- Settings and budget gates
- Scout guard
- Team 配置
- Parent/child context conventions
- Delivery and resume
- Herdr transition and role migration
- Changelog
- Development
Architecture
User (CEO) — root Pi session (TUI)
├─ pi-legion extension
│ ├─ LegionSupervisor (root)
│ │ ├─ RpcClient × N → headless `pi --mode rpc` child processes
│ │ ├─ mail postman (inbox delivery) + four-phase state machine
│ │ └─ orphan scan + child lifecycle / usage accounting
│ ├─ `legion.db` (one shared SQLite/WAL tree ledger)
│ └─ LegionEventBus → resident tree | `/legion` full view | live overlay
└─ centurion child (RpcClient; cwd = its subtree worktree)
└─ pi-legion in child mode
├─ LegionSupervisor for this squad
├─ `legion_mail` / `legion_tree` / `legion_phase` tools
├─ worker / scout / reviewer … children (`worktree: false`)
└─ nested centurions → recursive squads
Transport, state, decision-making, and display are separate layers:
- Transport: each child is a headless Pi RPC process managed by a
LegionSupervisor; a centurion can supervise another squad recursively. - State: all nodes in the tree share the
legion.dbSQLite ledger in WAL mode. Per-runrun.jsonrecords and session files remain the resume source for each child. - Decision: centurions and squad members do the LLM work. The supervisor owns machine state and validates phase transitions.
- Display: the event bus feeds the resident tree, the
/legionfull view, and an on-demand live overlay.
Spawn mode
spawnMode picks the backend that runs one child. Per role it comes from agent
frontmatter → subagents.agentOverrides.<role> → team member override:
# agents/scout.md
---
spawnMode: inprocess # or: rpc
---
Default: omitting spawnMode means inprocess — the child runs as an
AgentSession inside the parent Node process (createAgentSession), which
trades process isolation for spawn latency.
Automatic fallback to rpc: when spawnMode is omitted and the resolved
spawn cannot be in-process, Legion launches it over pi --mode rpc instead.
That happens when the role needs an OS worktree (worktree: true — the writer
and centurion default). A kind !== "pi" role is rejected earlier with
INVALID_PARAMS (only kind: pi children are supported at all), so it never
reaches this fallback.
Explicit is strict: spawnMode: "inprocess" with worktree: true is
refused with INVALID_PARAMS rather than silently downgraded, so a role that
asks for in-process never gets a different transport behind its back.
Limits for the in-process backend are separate (subagents.inprocess.*) and are
not shared with the legion.* tree budgets — see
docs/inprocess-supervisor.md.
Headless child contract
Legion children on the rpc backend are rpc only (pi --mode rpc).
Headless modes have no
interactive tool-approval UI. Project .pi/settings.json / project extensions
load under headless only after /trust (or defaultProjectTrust=always), or
when you opt in with subagents.rpc.approve: true (appends --approve). Legion
onBlocked is a parent subagent block strategy — not a pi tool-approval
replacement; keep the layers separate.
Full mapping (exit → SettleResult → node state) and the three upstream headless
modes vs legion: docs/headless-child-contract.md.
Quick start
Install and load the extension in Pi:
pi install npm:pi-legion
Ask a centurion to lead a recursive squad, with a goal and concrete
acceptance checklist in its task:
subagent({
agent: "centurion",
task: "Goal: implement the account export flow. Acceptance: contract is recorded; implementation is reviewed; the verification commands pass; return a squash MR and verdict."
})
The centurion plans the work, writes the squad contract, dispatches ordinary
roles such as worker, scout, and reviewer, and coordinates the squad
through its phases. For a single leaf task, dispatch an ordinary role directly:
subagent({ agent: "scout", task: "Map the export code path and report relevant files and risks." })
No manual child-process launch is needed. Use /legion to inspect the tree;
see Three-layer visualization for navigation.
/legion status prints that tree in the chat, and /legion status <name>
prints one node — see Status.
The subagent control actions include event-driven wait and graceful stop:
subagent({ action: "wait", name: "worker-0" }) returns as soon as the child
settles (or the parent turn is interrupted with Esc). Esc only ends the parent
wait — children keep running and settle still notifies; retire them with
/legion stop or by exiting the session. Launch stays async by default. Under
pi --print or JSON mode the process would otherwise exit at the end of the
parent turn and retire those children before they write an assistant message.
That shutdown waits for each live async child to settle, bounded by the
child's turn timeout, and only then retires whatever is still running. A
child killed by that shutdown is recorded as aborted because the parent
session shut down, not because the model produced no reply. To include the
child's result in the same print turn, call wait or collect before the
turn ends, or launch with async: false. To stop children explicitly, use
subagent({ action: "stop", name: "worker-0" }) or
subagent({ action: "stop", all: true }); this runs the normal graceful-retire
sequence for each selected child. Pass force: true to escalate to hard retire
when a child ignores soft steer / graceful close.
Stopping async subagents yourself
Esc never retires children (see docs/contracts/esc-no-retire-v3.md). Two user
controls run graceful stop (a stopped parent retires its own subtree on
shutdown); process exit / session shutdown also retires:
/legion stopstops every live subagent;/legion stop <name>stops one (allis an alias for no name). Names complete from the live children; nested agents complete asparent/nameand are reported as owned by their parent (stop the parent to retire them).xon the selected live agent in the/legionview or its live overlay asksyto confirm in place, then stops it.- Tool:
subagent({ action: "stop", name })is graceful; passforce: truefor hard retire (SIGTERM → SIGKILL + descendant sweep perdocs/retire-hardening-contract.md). Example:subagent({ action: "stop", name: "worker-0", force: true }). TUI/legion stop --forceis an optional follow-up (sameforce: true).
wait ≠ stop. A wait timeout (or still-running result) does not
retire children. The tool result includes live child names, elapsed ms, and
suggested decisions (extend / steer_again / stop / force_stop); the
parent chooses the next tool call. See docs/contracts/wait-not-stop.md.
While subagents are live, the railway header can show a dim
/legion stop [name|all] hint (shortened on narrow terminals; idle gating /
tree-strip coverage is a TUI follow-up).
Status (/legion status)
The railway and /legion view are TUI surfaces. /legion status is the chat
snapshot of the same tree:
/legion statusprints every node with the same lines aslegion_tree(id role=… status=… phase=… model=… depth=…), plus a count of each status. Live jobs that are not in the tree yet are listed under that summary./legion status <name>prints one node with the same text assubagent({ action: "status", name }). Living children lead withalive · quiet Ns(session JSONL mtime); after ~30s of silence the line becomesalive · quiet Ns (living)so 「活着但安静」is obvious. Run-store execution/acceptance fields are labeled snapshots, and collect-deadline / transient-connection reasons are omitted from the snapshot line while the child is alive so they do not steal attention from quiet age. A ledger-only node still uses the same renderer with the fields the tree has.- Quiet age is not a heartbeat: there is no child-side progress ping. Minutes
of silence can still mean the model is reasoning (especially high-thinking
turns). Quiet alone does not mean the child is stuck; pair it with
alive. - Both forms work outside the TUI. A short name that matches more than one
node needs the full node id (
/legion status <node-id>).
Legion tools and phases
Child nodes receive these tree-aware tools:
legion_mail({ to, subject, body, kind?, urgency? })
legion_tree({ scope?: "children" | "squad" | "subtree" | "tree", depth? })
legion_phase({ to, note?, evidence? })
legion_mailsends asynchronous messages over permitted tree edges: parent, children, or same-parent siblings.tomay be a short name, node ID,parent,children, orsquad. Cross-squad direct mail and bypassing the parent are refused; route cross-squad requests through the relevant centurions. Keep large artifacts in the shared worktree and mail a file pointer. The body has a 4 KB soft limit and a 32 KB hard limit.legion_treeis a read-only view of machine-recorded node state. It does not interrupt the nodes being observed.legion_phaseis available to centurions. It requests a state transition; the supervisor checks the transition's invariants and records the result.
A squad moves through planning → implementing → reviewing → verifying.
Planning produces a non-empty .pi-legion/contract.md with file ownership
and acceptance commands. During reviewing, an independent reviewer supplies
the judgment; in verifying, the supervisor runs the contract's legion-verify
commands without an LLM. Passing verification advances the squad to done.
Three-layer visualization
- Railway strip above the input: one git-graph lane per live subagent
(max 4, 3 below 80 columns; oldest/shallowest first,
+Nfor the rest). When an agent ends its lane merges back and lower lanes shift up. The header shows the live count and tok/s (trailing 60s average). The lane label readsrole · <time since the last task started> / <lifetime> · <age of last activity>; the leading clock keeps ticking through the quiet gap after a turn settles and freezes only when the agent itself ends. The lane column is measured from what the labels need and the time axis takes the rest (down to a floor), so a row always fills the line. Hidden when no subagent is live. SetPI_LEGION_STRIP=treefor the classic roster. /legionfull view: galaxy graph of the agent tree, stacked tok/s chart by agent type (peak marker, rate/avg/peak/total, per-role share), and a scrollablelog --graphhistory. Tab switches panes, arrows scroll or move the selection, Enter selects an agent (its own rate line, tok/s, tokens and share are overlaid),oopens its live overlay,xstops it (confirm withy), Esc clears the selection,q/Esc closes. Layouts: wide (≥140 cols), stacked, and portrait (<80 cols, or tall and <100 cols)./legion --treeopens the classic tree (j/k, Enter/Space fold,/search, PageUp/PageDown).- Live overlay: press
oon the selected node, use a component-routed mouse click in the strip or classic tree, or open it directly with/legion <name>. The overlay subscribes to that node's live feed only while open; Esc closes it (returning to/legionwhen opened from there) and unsubscribes;xstops the agent after ayconfirmation. OSC 8 links are visual anchors only, not overlay links.
Settings and budget gates
Add the v2 legion block to ~/.pi/agent/settings.json or the project
.pi/settings.json, merging it with any existing subagents keys:
{
"subagents": {
"legion": {
"maxDepth": 4,
"maxChildrenPerNode": 8,
"maxActiveNodes": 30,
"phaseTimeouts": {
"planning": 900000,
"implementing": 3600000,
"reviewing": 1800000,
"verifying": 900000
},
"verifyCommandTimeoutMs": 600000,
"mailRatePer5Min": 10,
"usagePollMs": 30000,
"orphanStaleMs": 90000
}
}
}
The three launch budget gates are enforced against the tree ledger:
| Gate | Setting | Default | Limit |
|---|---|---|---|
| Depth | maxDepth |
4 | Maximum tree depth; the hard cap is 4 (MAX_NESTED_PATH_ENTRIES). |
| Fan-out | maxChildrenPerNode |
8 | Direct children for one node. |
| Concurrency | maxActiveNodes |
30 | Whole-tree running + starting nodes. |
The effective tree depth cap is bounded by the operator override,
subagents.legion.maxDepth, and the hard cap of 4. INCL semantics: a
launched role's maxSubagentDepth = R counts generations in that role's
subtree, including itself; when launched by a parent at depth d, it inherits
C_child = min(C_parent, d + R, 4) (R = 1 makes it a leaf), so it can tighten
the inherited cap but cannot widen it.
Scout guard
subagents.scoutGuard watches the main session. After threshold consecutive
read-like tool calls with no subagent launch or vexillatio call in
between, it injects one follow-up reminder and then waits out cooldownMs.
It is on by default. Set it in ~/.pi/agent/settings.json or the project
.pi/settings.json; a project file overrides the same fields from the user
file.
{
"subagents": {
"scoutGuard": {
"enabled": true,
"threshold": 8,
"cooldownMs": 120000
}
}
}
| Field | Default | Meaning |
|---|---|---|
enabled |
true |
Count and remind. false skips the guard until it is turned back on. |
threshold |
8 |
Consecutive read-like calls before one reminder. Positive integer. |
cooldownMs |
120000 |
Quiet period after a reminder, in milliseconds. Integer ≥ 0. |
/scout-guard turns the guard on for this session and /no-scout-guard
turns it off. Both override enabled in memory only; settings files stay
unchanged. Child sessions do not register these commands. Behavior in full
is in docs/design.md §6.4.1.
Team 配置
Use subagents.team to select the default named team from subagents.teams.
The PI_SUBAGENTS_TEAM environment variable takes precedence over
subagents.team; if neither selects a named team, the full discovered roster
is used. Team definitions have a description and an ordered members list;
each member can be a role-name string or an object with an agent name and
per-member overrides. Object member overrides (for example model or
thinking) take precedence over those fields in the role card's frontmatter;
omitted override fields continue to use their normal role/configuration
values.
Teams may also set optional parentContext / childContext (same value forms
as the global keys, including @path and false). When the active team
defines a side, that value replaces the global context for that side — it
does not merge. Omit the field to fall back to global (or convention files);
set false to disable that side with no fallthrough. Unknown team names keep
today's warn + full roster behaviour and still use global context.
See docs/subagents-teams.md for editable frontend/backend team templates with distinct workflow text (not only different models). The model IDs in that template are placeholders for your provider, not required defaults. For running a multi-agent QA hunting round in this repo, see docs/qa-hunting-round.md (round orchestration) and docs/tui-qa-playbook.md (evidence rules).
Parent/child context conventions
Settings can provide subagents.parentContext and subagents.childContext
inline or through @path references. If either setting is omitted, Legion
looks for subagents-parent-context.md or subagents-child-context.md
respectively in the directory containing that settings file. These are
convention-based fallbacks: an explicit setting takes precedence, and setting
either field to false disables its context. Named teams can override each
side via subagents.teams.<name>.parentContext /
childContext (replace, never merge; see Team 配置). The
convention filenames are PARENT_CONTEXT_CONVENTION and
CHILD_CONTEXT_CONVENTION in
src/agents/settings.ts; missing convention
files simply contribute no context. The repository examples are
examples/subagents-parent-context.md
and examples/subagents-child-context.md.
Self-diagnosis (/legion doctor)
When something about subagents misbehaves (model not found, spawn exit, memory
gate, missing lens tools, orphan temps, …), ask Legion rather than guessing —
same idea as pi list / pi auth check.
/legion doctor— full report in the chat (works outside TUI too)./legion doctor [section]— one of:summary,extensions,models,spawn,memory,tools,storage,runtime,errors,next.subagent({ action: "diagnose" })— same full report as tool output so the main agent can call it without waiting for you. The tool has no section field; filter with/legion doctor [section].
The report is structured for the model: each section ends in Issues /
Next steps with copy-pasteable fixes (env exports, role frontmatter,
/legion stop, …). Spawn failures that look like a missing model also append
a short Hint automatically. Gated auto-doctor: on model-not-found,
INSUFFICIENT_MEMORY, extension load failures, and child exit during
startup / first prompt, the tool error itself includes a focused doctor
snippet (relevant sections + Next steps) so the main agent can self-fix
without anyone typing /legion doctor. Full /legion doctor remains for
manual deep dives.
Session errors (#160): tool validation / call failures from subagent
(and similar) append to .pi-subagents/session-errors.jsonl (JSONL, rotated
to ~500 lines). /legion doctor / diagnose show counts, by-code tallies,
and the last few one-line titles under the errors section; when the log is
large they print only that summary plus the log path.
Coverage:
| Section | What it checks |
|---|---|
| extensions | Host packages, resolved --extension paths, --no-extensions / inherit knobs, per-role effective extension list |
| models | Host vs child model lists, which package restores providers, team/roster gaps |
| spawn | Sample child argv, recent spawn failures + stderr, nested-spawn flags |
| memory | Free RAM, memory gate, child V8 flags, verify concurrency slots in use |
| tools | /legion slash list (status, team, agents, summary, on/off, scout-guard, doctor, stop), idle-Esc / strip hints, tool names |
| storage | legion.db sizes, orphan rpc-child-* temps, effective hygiene limits (default vs env), last sweep marker plus a hint when it is missing or stale |
| runtime | Live children, tree statuses, acceptance-ish / active nodes |
| errors | Recent session/tool errors (validation failures, etc.) as a short summary; full journal at .pi-subagents/session-errors.jsonl (rotated ~500 lines). Large logs: summary + path only — doctor never dumps the whole file |
Child extensions
Children start with --no-extensions, so they do not load the host's
discovered extensions (settings packages, ~/.pi/agent/extensions, project
extensions). Each extension a child loads is a memory cost multiplied by every
concurrent child; an LSP stack is ~1 GB (#43). A child still loads pi-legion
itself (guard and nested spawning) and every path listed in the role's
extensions / subagentOnlyExtensions.
| Knob | Effect |
|---|---|
role frontmatter inheritExtensions: true |
That role loads all host extensions, as before. |
PI_LEGION_CHILD_INHERIT_EXTENSIONS=1 |
Every child loads all host extensions, as before. |
PI_LEGION_CHILD_EXTENSIONS=/a.ts,/b.ts |
Extra extension paths every child loads explicitly, e.g. a provider extension the child's model needs. |
Bundled planner, reviewer, and worker do not list lens_* tools. pi-lens starts an LSP stack (~1 GB per child, #43) and is not part of the default --no-extensions child. To give a role those tools, add the tool names to its tools: list and point extensions: / subagentOnlyExtensions (or PI_LEGION_CHILD_EXTENSIONS) at the pi-lens extension file. /legion doctor reports ROLE_MISSING_LENS when a role lists the tools without that path.
Extra agent directories
PATH-style directories scanned for agent *.md definitions. They load as
user-level, below the real user agents dir in precedence (a same-name file
in ~/.pi/agent/agents still wins). Useful for hermetic installs (Nix store,
container, read-only mount).
| Env | Effect |
|---|---|
PI_LEGION_EXTRA_AGENT_DIRS |
Canonical name. Colon-/semicolon-separated absolute dirs (path.delimiter). |
PI_HERDR_SUBAGENTS_EXTRA_AGENT_DIRS |
Deprecated compatibility alias (kept for at least one minor version). If both are set, PI_LEGION_EXTRA_AGENT_DIRS wins and the legacy value is ignored (one stderr warning). |
Child memory controls
| Env | Default | Effect |
|---|---|---|
PI_LEGION_CHILD_SEMI_SPACE_MB |
1 | Child pi --max-semi-space-size (via NODE_OPTIONS). 0 = off. |
PI_LEGION_CHILD_MAX_OLD_SPACE_MB |
1024 | Soft child heap cap --max-old-space-size. 0 = off. A host NODE_OPTIONS value wins. |
PI_LEGION_MIN_FREE_MEM_MB |
1024 | Memory gate: a spawn is refused (INSUFFICIENT_MEMORY, plus a UI notice) when available memory minus reservations falls below this. 0 = off. |
PI_LEGION_CHILD_RESERVE_MB |
160 | Per-child reservation for children still starting, so a parallel burst cannot all pass one reading. |
PI_LEGION_VERIFY_CONCURRENCY |
2 | Machine-wide limit on concurrent acceptance / legion-verify commands, using slot lock files. 0 = unlimited. |
PI_LEGION_VERIFY_QUEUE_MAX_MS |
1800000 | Overall queue-wait cap. After it expires the command runs without a slot. Queue wait does not count toward the command timeout. |
PI_LEGION_VERIFY_LOCK_DIR |
$TMPDIR/pi-legion-verify-<uid> |
Slot directory. Set it to scope the limit more narrowly. |
The child restores the host NODE_OPTIONS when its extension loads, so bash
commands it runs (tsc, npm test) keep the default heap.
Storage hygiene
Each root pi session runs one deferred, best-effort sweep of .pi-subagents/
at startup. A step only touches state whose owner process is gone: runs and
rpc-child-* temp dirs record the owner pid (owner.json / .owner). Set any
limit to 0 to disable that step.
| Env | Default | Effect |
|---|---|---|
PI_LEGION_HYGIENE |
on | 0/off disables the whole sweep. |
PI_LEGION_RUN_RETENTION_DAYS |
14 | Delete run dirs idle this long. Runs with a worktrees/ entry are kept as resume credentials. |
PI_LEGION_STALE_NODE_HOURS |
72 | Non-root ledger nodes still starting/running/blocked this long after their owner died are marked failed (node_abandoned event). |
PI_LEGION_STALE_TEMP_HOURS |
24 | Age threshold for legacy rpc-child-* dirs without an owner marker. |
PI_LEGION_EVENT_RETENTION_DAYS |
30 | Drop ledger events of settled/failed nodes older than this (orphan_detected, budget_refused, node_abandoned are kept). |
PI_LEGION_EVENT_MAX_ROWS |
200000 | Soft cap on the events table. Oldest terminal-node rows are deleted first. |
The live legion.db gets event retention and a wal_checkpoint(TRUNCATE) on
session shutdown. A large delete is followed by VACUUM, which is skipped when
another connection holds the db.
Delivery and resume
A centurion owns a Git worktree for its subtree; its worker/scout/reviewer children work in that subtree, and a nested centurion gets a nested worktree. Each subtree is delivered as a squash MR to its parent level. MRs move upward through the tree until the root reviews and merges into the default branch.
Resuming follows the tree's saved sessions and node records. If a supervisor process ends, its child processes do not become detached workers; resuming the parent restores the squad, while already-settled children are not restarted. Keep the run records and session files while the tree may need to resume.
Herdr transition and role migration
Version 0.16.x is the final Herdr-era line. Pi Legion 2.x is RPC-only:
child execution uses headless pi --mode rpc processes, and the legacy backend
is archived under src/backends/legacy/, not offered as a v2 runtime option.
Pin to pi-legion 0.16.x if a workflow must stay on the former Herdr-based
execution model. A short breaking-change summary lives in
CHANGELOG.md.
Pi Legion 2.x supports kind: pi only. Migrate a custom role that uses another
CLI kind by changing its frontmatter, for example:
---
name: search
kind: pi
---
A non-Pi kind is rejected by v2. If that role must continue to run through another CLI, keep it on the 0.16.x line or replace it with a Pi role.
Development
pnpm install
pnpm run typecheck
pnpm test # unit tests
pnpm run test:integration # integration tests
pnpm run test:all # typecheck + unit + integration tests
Mock LLM (offline pi / legion smoke)
A scriptable OpenAI-compatible server lives under test/mock-llm/.
It registers with Pi like any local gateway (models.json → api: "openai-completions"),
so spawn / stop / doctor / verify paths can run without real API keys:
pnpm run mock-llm # http://127.0.0.1:8765/v1 scenario=echo
pnpm run test:mock-llm # unit + integration smoke for the mock
MOCK_LLM_SCENARIO=spawn-scout pnpm run mock-llm
Use writeMockPiHome() from test/mock-llm to generate an isolated
PI_CODING_AGENT_DIR with dummy apiKey: "mock" only — never copy host
auth.json into fixtures.
See CONTRIBUTING.md for the project workflow: issues, pull requests, the merge gate, and releases (deps via pnpm; npm stage publish for staging).
Version history for consumers: CHANGELOG.md. Tag-by-tag release bodies: GitHub Releases.
MIT. See LICENSE.