pi-async
Pi extension for async shell tasks: background/delayed/sync modes, output-matching watches, event-driven waiting, settle guard for legion children, and a TUI task rail — completion notifications wake your session.
Package details
Install pi-async from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-async- Package
pi-async- Version
0.4.0- Published
- Nov 7, 2026
- Downloads
- 345/mo · 24/wk
- Author
- zzjcool
- License
- MIT
- Types
- extension
- Size
- 347.9 KB
- Dependencies
- 0 dependencies · 2 peers
Pi manifest JSON
{
"extensions": [
"./extensions/index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-async
Pi extension for running shell commands asynchronously with bounded output capture, completion notifications, and a task panel.
Tasks run in the session working directory — the directory pi is currently in, not the directory the pi process was started from. Every task records its cwd, so delayed tasks and /reload recovery keep the directory they were submitted from.
Install
Install the package from npm with Pi's package manager:
pi install npm:pi-async
Local path (the other form of the same command, for a checkout):
pi install /path/to/pi-async
For development, load the extension directly:
pi -e ./extensions/index.ts
Tools and commands
The extension registers six tools:
| Tool | Purpose |
|---|---|
async_run |
Start a background, delayed, or synchronous shell command. |
async_wait |
Wait for task completion or return a notReady result on timeout. |
async_logs |
Read bounded, ANSI-stripped captured output (merged / stdout / stderr). |
async_status |
List tasks and their current state. |
async_extend |
Renew a running task: move its reminder and/or hard-timeout deadline. Never kills. |
async_kill |
Terminate a task and its process group. |
async_wait, async_logs, async_extend, and async_kill accept a task name as well as a task id. Ambiguous names are rejected with the candidate ids listed; unknown ids/names are an error instead of a silent success.
How the model finds these tools
All six tools carry a one-line promptSnippet, so the host lists every one of them in the system prompt's
tools section — async_run is not the only entry point the model can see.
async_run additionally ships three promptGuidelines in the system prompt: when to reach for it instead
of bash, that ending a turn with running tasks is expected (the completion notification wakes the
session), and that name + async_logs beat re-running a command.
The procedural detail — check-writing discipline, watch semantics, promote vs. abort, legion collection —
ships as an Agent Skill (skills/async-tasks/SKILL.md, advertised through resources_discover), so it
costs one routing line until the task actually needs it.
Two runtime nudges close the gap that a static prompt cannot:
- after a foreground
bashcall of 60 s or more — or atail -f/until/watch/sleep Nshaped command — the tool result gets one extra line suggestingasync_run. When the host does not report a wall time, the extension times the call fromtool_execution_startinstead of guessing; - while tasks are active, one
<async-state>line is appended before each request (nothing is injected when idle, and an unchanged state is not repeated). Its closing sentence follows the per-task truth: a task started withnotifyOnCompletion: false(ortriggerOnCompletion: false) is markedno-notify, and when nothing can wake the session the line says so instead of promising a notification.
The general rule lives in the system prompt once; a receipt only adds the exception for that call
(willNotify: false / willWake: false plus a one-line hint). Both nudges are advisory and best-effort;
PI_ASYNC_NUDGE=0 disables them.
Tool notes:
async_run:modeisbackground(default),delayed(requiresdelayMs) orsync(requirestimeoutMs).timeoutMsis sync-only,delayMsis delayed-only,onTimeoutis sync-only — passing them to the wrong mode is rejected.onTimeout: "promote"(default) hands an expired wait to background;"abort"stops waiting only, the command keeps running and still notifies. A sync run that finishes without exit 0 returnsisError: truewith the full payload (status/exitCode/outputTail/promoted) intact.promoted: truemeans the sync wait expired and the task was handed to background (status: "running") — a promoted or aborted wait is not a tool error.async_status: defaults to active (running/pending) tasks.includeHistory: truelists up tolimit(1–50, default 50) tasks sorted newest first; an explicitstatusalso sorts newest first.statusandname(case-insensitive substring) filter the list.delayedEnvvalues are masked as"***". Snapshots exposenotified,outputConsumed,notifyAttempts,cwd,timeoutAt, andwatchDeadline. A delayed task that fails to start (missingcwd, rejectedenvkey, spawn error) is finalized asfailed(exit 1) and records the cause inmetadata.startError.async_logs:streamselects the merged capture or a single stream;maxBytesis capped at 50 KiB. A missing output file returnsmissing: trueinstead of throwing; paths outside the data directory are refused.async_kill: returnskillKind,alreadyTerminal,processAliveand, when the kill could not be confirmed,warning.
| Command / key | Action |
|---|---|
/async |
Open the task panel. The panel is a singleton: running it again while open only shows an Async panel is already open notice and does not stack another overlay — the open panel refreshes itself from registry events. |
/async-clear |
Clear completed-task history badges. |
↑ / ↓ / j / k |
Select tasks; in task details, scroll output. |
Enter |
Open selected task details. |
h |
Toggle history in the task list. |
x (or K) |
Kill a running task (press again to confirm). |
R |
Rerun the selected task command in that task's original cwd. |
c |
Show the captured output file path as a toast. Nothing is copied: pi exposes no clipboard API, so there is no copy action. |
Esc |
Close rail-clicked details; return from list-opened details to the list. |
q |
Close the panel. |
The rail's first row summarizes running, pending, elapsed, completed, and failed/killed task counts; pending and failed/killed counts are shown only when nonzero. Mouse support (wheel, click) exists only in fullscreen / alt-screen TUI mode — pi only enables mouse reporting there; in regular mode use /async and the keyboard. The panel and rail are only enabled in the main Pi TUI session; RPC, print, and child contexts retain the tool surface without TUI entrypoints.
Configuration
| Environment variable | Default | Description |
|---|---|---|
PI_ASYNC_MAX_OUTPUT_BYTES |
20971520 (20 MiB) |
Maximum captured output per task; exceeding it terminates the task with output_cap. |
PI_ASYNC_MAX_TASKS |
32 |
Maximum concurrent running and pending tasks. |
PI_ASYNC_MAX_SYNC_TIMEOUT_MS |
600000 (10 min) |
Upper bound for sync.timeoutMs (schema maximum and runtime check). |
PI_ASYNC_ENV_INHERIT |
safe |
Child env policy: safe strips credential-like variables and rejects dangerous explicit keys; all restores full inheritance (explicit dangerous-key overrides are still rejected in every policy); none passes only a minimal allowlist (PATH, HOME, SHELL, TERM, LANG, LC_*, TMPDIR, USER). |
PI_ASYNC_SETTLE_GUARD |
on |
Set 0 to disable the settle guard entirely (no agent_before_settle reminder, no agent_settled backstop). |
PI_ASYNC_NUDGE |
on |
Set 0 to disable the runtime context nudges (the long-command hint and the <async-state> line). |
PI_ASYNC_HOLD |
on |
Set 0 to disable the settle hold entirely (no agent_before_settle handler is registered). |
PI_ASYNC_HOLD_MS |
600000 (10 min) |
Upper bound for one settle hold; ≤ 0 disables the feature (a negative value is not replaced by the default, and non-integer values fall back to the default). |
PI_ASYNC_HOLD_POLL_MS |
1000 |
How often the task list is re-read while the run is being held. |
PI_ASYNC_FOOTER |
off |
Set 1 to also render a one-line task summary through the host's setStatus footer (only when the host exposes setStatus). The rail's first row is the default status surface. |
PI_ASYNC_RETENTION_MS |
604800000 (7 days) |
How long terminal-task artifacts are retained; union with RETENTION_COUNT. |
PI_ASYNC_RETENTION_COUNT |
100 |
Max newest terminal tasks kept per dataDir; union with RETENTION_MS. |
PI_ASYNC_RAIL_LINGER_MS |
30000 |
How long completed/failed/killed tasks stay on the rail before disappearing. |
Under the default safe policy, inherited variables matching *_API_KEY, *_TOKEN, *_SECRET, *_PASSWORD, *_CREDENTIAL*, plus AWS_SECRET_ACCESS_KEY, AWS_ACCESS_KEY_ID, AWS_SESSION_TOKEN, GITHUB_TOKEN, GH_TOKEN, and NPM_TOKEN are removed. npm_config_* keys whose name contains auth, token, or password (for example npm_config__authToken, which npm reads as a credential) are removed as well, so npm credentials exported by the parent shell do not leak into tasks; ordinary npm settings such as npm_config_registry still pass through. Passing a dangerous key explicitly through env (PATH, LD_PRELOAD, LD_LIBRARY_PATH, NODE_OPTIONS, BASH_ENV, ENV, SHELLOPTS, PYTHONPATH, GIT_SSH_COMMAND) throws instead of being silently ignored — under every policy, including all; the safety rule cannot be switched off. Ordinary keys may still be overridden.
For delayed tasks the submitted env is persisted to <taskId>.json (mode 0600) so the task can still start after /reload; the tool surface and the panel render its values masked as "***" (keys survive).
Notifications
Completion notifications are best-effort. pi.sendMessage returns void — the host is fire-and-forget and gives no acknowledgement, so pi-async sends once, never retries, and never rolls back. A "queued" notification means it was handed to the host, not that the model read it.
notifiedbecomes true only aftersendMessagereturns; a failure or a missing host API leavesnotified: falseand incrementsnotifyAttempts.- On
session_start, pi-async resends terminal tasks that are still unnotified and unconsumed (within 24 h and under 2 attempts), so a missed wake-up is retried once per session start. async_waitresults are marked consumed (outputConsumed), which suppresses the completion notification for those tasks to avoid chatter. On a hybrid timeout only the completed side is consumed; not-ready tasks are left alone.async_statusexposesnotified/outputConsumed/notifyAttempts, so an agent can check whether it already received (or suppressed) a result.notifyOnCompletion: falseopts a task out entirely.- A delayed task whose start failed records
metadata.startError; the failure notification'sguidanceincludes it asStart error: ..., so the cause (missingcwd, rejectedenvkey, spawn failure) is visible without an extraasync_logscall. - A task killed by
taskTimeoutMsstayskilled, but its notification carriesguidance(growtaskTimeoutMsor usewatch) — it is the onekilledshape where the next step matters.completednotifications stay pure information.
Settle hold (default on)
Settling is what flips the terminal OSC 7501 status from working to done. When an interactive TUI
session reaches the end of a turn while async tasks that will wake it are still running, pi-async
holds the run at the agent_before_settle boundary instead of settling, so the run keeps reporting
working for the whole wait.
- Only wakeable
runningtasks are waited for:notifyOnCompletion !== falseandtriggerOnCompletion !== false.pendingtasks (amode: "delayed"run) are never held — theirdueAtcan be arbitrarily far away, so waiting for one would burn the whole budget at every turn end. The set is snapshotted when the boundary opens, so a task started while it waits is not waited for. - Bounded: at most
PI_ASYNC_HOLD_MSper run (default 600000 ms = 10 min, measured on a monotonic clock) and one hold per run (agent_startre-arms it). The loop has exactly two exits — every task in the snapshot is terminal, or the budget ran out; it never probes the host's message queue, because a notification queued during the hold lands onagent.followUpQueuewhilectx.hasPendingMessages()only counts the session-level user queues. On timeout the run settles exactly as before — nothing is consumed, killed, or suppressed. - When every task in the snapshot finished, the boundary returns one
async-task-settle-resultsdraft (name, status, exit code — never the output tail, useasync_logs) pluscontinue: true, so the outcome reaches the transcript even if the wake-up was dropped. That cannot loop: the next boundary sees only terminal tasks and returns nothing. - Only an interactive TUI session holds.
print/jsonwould delay the process exit,rpcaborts under a client-side timeout, and legion child sessions keep their own bounded reminder channel — none of them are affected. - While it waits the footer shows
waiting for N tasks <elapsed>, refreshed every 15 s, whenever the host exposessetStatus.
| Environment variable | Default | Description |
|---|---|---|
PI_ASYNC_HOLD |
on |
Set 0 to disable the hold entirely (no handler is registered at all). |
PI_ASYNC_HOLD_MS |
600000 (10 min) |
Upper bound for one hold; ≤ 0 disables the feature (a negative value is not replaced by the default; non-integer values fall back to the default). |
PI_ASYNC_HOLD_POLL_MS |
1000 |
How often the task list is re-read while holding. |
Start long-lived services (a dev server, a watcher you keep across turns) with
notifyOnCompletion: false: such a task is still running at every turn end, so with the default it
would hold every turn until PI_ASYNC_HOLD_MS.
Pressing Esc during a hold does not interrupt it — the host only marks the run aborted and cannot
signal the boundary (ctx.signal is gone by then), so the hold keeps waiting and can run up to
PI_ASYNC_HOLD_MS before the run settles as aborted. Two different things then happen to the wake-ups:
- a task that finished during the hold and before the Esc had its completion notification queued and
then cleared by the host's
clearAllQueues(); becausenotifiedis already latched it is never re-sent, so that task will not wake you — its outcome is still written into the transcript by theasync-task-settle-resultsdraft, so the next turn you start can act on it; - a task that finishes after the Esc is not affected: its notification is delivered normally and still wakes the session.
Fixing the first case means changing the single-delivery semantics of notify.ts (or finding a host
signal the boundary can read), which is out of scope for this feature.
Watches
- Conditions inside a group (
success_when,failure_when) are OR-ed: the first match wins. There is no AND mode; use one condition with a richerstdout_containsvalue if you need a conjunction. success_whenrecords completion without killing the process (task lifetime still belongs totaskTimeoutMs/async_kill);failure_when, blind exhaustion, and watch timeout all kill the process group.blind_checks(default 3) counts only infrastructure errors (e.g. the output file could not be read). "Not yet decidable" states — process still running, JSON not valid yet — stay pending and never accumulate blind checks.output_matchessees only the trailing 64K characters of output (UTF-16 code units, not bytes), so a marker far from the tail falls out of the window — anchor accordingly.stdout_contains/stderr_containsscan the captured stream, but every poll reads only the last 8 MiB, so anything older is invisible to every condition.output_matchescompiles with themflag, so^/$are line boundaries.
Data directory
Captured artifacts live per session:
- With a session manager:
<session dir>/pi-async/<sessionId>/ - Without one:
~/.pi/async/<pid>/
Each task writes <taskId>.json (atomically replaced metadata) plus <taskId>.output and its .stdout / .stderr sidecars. Files are created with mode 0600.
Do not point the data directory at a git-tracked path: captured output can contain credentials, tokens, or other secrets a command printed. Terminal-task artifacts are pruned by PI_ASYNC_RETENTION_MS ∪ PI_ASYNC_RETENTION_COUNT; non-terminal tasks are never pruned directly — orphan detection (configure() → reapOrphans()) first marks running tasks whose pid is gone as failed (failureKind: "lost") at startup, and marks pending delayed tasks as lost when their due time is older than PI_ASYNC_RETENTION_MS (the count setting does not apply to them). Pending delayed tasks still inside that window are restored with their remaining delay.
If the resolved data directory changes inside a live process, artifacts are copied to the new location; if migration fails, pi-async logs the error and keeps the old directory so no task is lost.
Legion child sessions
In a legion child session, collect task results with async_wait before returning a verdict. The settle guard pushes a reminder into the model context before settling (up to 3 times, custom_message draft, continue: true), and an aborted run with active tasks gets a follow-up reminder on agent_settled. In the main session the same boundary produces one advisory draft (no forced re-run) saying that stopping with background work is fine, because its completion notification will wake the session. Notifications themselves remain best-effort (see above); see CONTRACT.md §6 for the frozen notification and collection requirements.
Known limitations
- Crash-durability is not guaranteed: metadata is atomically replaced, but captured output is not fsynced. After a crash, orphaned running tasks are marked
failed/loston the next start, and pending delayed tasks are restored with their remaining delay by whichever session next loads the same data directory (process identity does not matter). Recovery inside the same process is complete for/reload(cwd,taskTimeoutMsdeadline, and watch deadline are rebuilt from disk). - Cross-turn wake-up in legion children depends on the host delivering follow-up messages (upstream pi-legion issue #156); the extension does not patch Pi or legion.
- The settle hold keeps the OSC 7501 status and the footer line in
working, but it does not relight the TUI working spinner: pi's harness already cleared it atagent_endand the extension has no API to bring it back. Pressing Esc during a hold only marks the run aborted (pi does not interrupt the awaiting handler) and the hold cannot observe it, so the hold waits out its budget (up toPI_ASYNC_HOLD_MS) before the run settles as aborted. Wake-ups are lost only for tasks that finished during the hold and before the Esc: their notifications were cleared with the agent queues andnotifiedis already latched, so they will not wake you — their outcome still reaches the transcript through the settle-result draft. Tasks that finish after the Esc wake the session normally. Input is not frozen. - Because a hold keeps the run busy,
waitForIdle()waits for it too: any command that doesawait session.abort()waits up toPI_ASYNC_HOLD_MSwhile a hold is in progress (e.g./treenavigation —interactive-mode.js:4758→agent-session.js:1999). Esc itself isvoid abort(), stays non-blocking, and does not take that path. - TUI rail and panel are available only in the main TUI session, not in RPC/print/child contexts.
pi --printnotification delivery requires an available configured provider. If no provider is available, end-to-end print-mode verification remains pending a user run.
Development checks
npm run typecheck
npm test
npm run verify # typecheck + test (same gate as prepublishOnly)
npm run adoption # adoption report from real session JSONL (last 15 sessions)
npm pack --dry-run
npm run adoption reads ~/.pi/agent/sessions/*/*.jsonl (--dir / --limit / --json to change scope)
and prints the bash duration distribution, the foreground wall time it cost, the sleep-polling share, and
how many calls the shipped nudge would have flagged. Use it to compare a change instead of trusting the
anecdote in a review doc.
Container QA
Run the plugin inside a clean container against the local model endpoint:
cd test/docker
make run # start container + drop into pi (default: workbuddy/hy4-preview-ioa)
make smoke # L1 model reachable -> L2 tools advertised -> L3 a tool really runs
make qa-up && make qa-tui SCENARIO=s1 # deterministic TUI scenario
make run reuses an already-running container, so it never tears down your session.
The container QA setup itself lives in test/docker/ (not shipped in the npm
package). See its README in the repository for the two containers, the required
mounts, and why L3 must not be skipped.
Known upstream issue (print mode)
pi --print + background task completing after the agent settles: the wake happens but the woken
run's turn_end boundary fails with Extension error (<boundary>): turn_end could not resolve the persisted assistant entry ID (pi 1.2.0 core, reproduced identically with pi-background-tasks —
upstream issue https://github.com/earendil-works/pi/issues/11001). RPC mode (legion subagents) and
turn-internal waits are unaffected.