4.1 KiB
pi-fanout
Non-blocking async agent fanout for pi.
Problem
The built-in subagent tool is powerful, but its execute() blocks until all
dispatched agents finish. While parallel tasks run concurrently internally, the main
pi session is frozen waiting for the final result.
Solution
pi-fanout turns subagent dispatch into a true async job queue:
fanout_dispatch— returns immediately with job IDs; agents run as detached processesfanout_status— poll running/done/failed counts at any timefanout_collect— retrieve final output from completed jobsfanout_abort— kill running jobs on demand
The main pi session stays unblocked. You can do other work, dispatch more jobs,
and collect results whenever they’re ready. When a background job finishes, the
extension sends a followUp message into the session so you know it’s time to
collect.
Architecture
pi main process
├─ pi-fanout extension
│ ├─ JobManager (in-memory + disk state in ~/.pi/fanout/jobs/)
│ └─ Poller (every 2s: check PIDs, notify on completion)
│
├─ detached pi child (Agent A) ── writes output to job dir
├─ detached pi child (Agent B) ── writes output to job dir
└─ detached pi child (Agent C) ── writes output to job dir
Jobs survive pi restarts because:
- Child processes are detached from the parent
- State is persisted as
meta.json+output.jsonlper job - On startup the extension rehydrates old jobs and checks if their PIDs are still alive
Install
pi use git:git.vpsj.de/jay/pi-fanout
Or clone into your extensions directory and add it to ~/.pi/extensions.json.
Usage
> fanout_dispatch tasks=[{agent:"worker", task:"Refactor auth.ts"}, {agent:"reviewer", task:"Review auth.ts"}]
Dispatched 2 job(s). IDs:
ltv123-abc
ltv124-def
> ... do other work in the main session ...
> fanout_status
Jobs: 2 total — 0 running, 0 queued, 2 done, 0 failed/aborted
> fanout_collect jobIds=["ltv123-abc","ltv124-def"]
[ltv123-abc] done (exit 0) [claude-sonnet-4]
Refactored auth.ts to use bearer tokens...
---
[ltv124-def] done (exit 0) [claude-sonnet-4]
The refactored auth.ts looks solid. One suggestion: ...
Tools
fanout_dispatch
Parameters:
tasks: array of{ agent, task, cwd?, model?, tools? }agentScope:"user" | "project" | "both"(default"user")
Returns: { dispatched: string[] } — job IDs.
fanout_status
Parameters:
jobIds?: filter to specific IDs (omit for all)includeDone?: include finished jobs in listing (defaulttrue)
Returns per-job: id, status, agent, task, exitCode, pid, turns, cost.
fanout_collect
Parameters:
jobIds: array of IDs to collect
Returns per-job: id, status, output (final assistant text), exitCode, usage, modelUsed, errorMessage.
fanout_abort
Parameters:
jobIds: array of IDs to kill
Sends SIGTERM, then SIGKILL after 5s if still running.
How it works with the agent loop
- The LLM calls
fanout_dispatch. - The tool returns in <50ms with job IDs.
- The LLM is free to continue the conversation, run other tools, or ask the user.
- Every 2 seconds the extension polls job PIDs.
- When a job transitions to
done/failed, the extension calls:
This injects a user-style message that triggers a follow-up turn when the agent is idle.pi.sendUserMessage(`Fanout job X completed ...`, { deliverAs: "followUp" }) - The LLM sees the notification and calls
fanout_collectto retrieve outputs. - The LLM acts on the collected results (e.g. synthesize a final answer, dispatch follow-up jobs).
Limitations / Roadmap
- No true server push into a running turn: If pi is mid-stream when a job finishes, the notification is queued as
followUpand processed when the current turn ends. - No job result streaming into the main session yet. Jobs are collected atomically after completion.
- No automatic
fanout_collect: The LLM must explicitly call it. Future versions could auto-inject a tool-call hint. - Jobs older than 24h are auto-cleaned on startup.
License
MIT