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.3.0- Published
- Nov 7, 2026
- Downloads
- 341/mo · 29/wk
- Author
- zzjcool
- License
- MIT
- Types
- extension
- Size
- 235.3 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 five 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_kill |
Terminate a task and its process group. |
async_wait, async_logs, 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.
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_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.
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.- The match window is the trailing 64K characters of output, not bytes, and early output scrolls out of it. Anchor regexes differently if the marker can appear far from the tail.
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), and an aborted run with active tasks gets a follow-up reminder on agent_settled. 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.
- 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 pack --dry-run
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.