Live testing exposed a valve conflict: suppressed lines still counted toward maxLinesPerMinute, so a line-heavy flood was killed by the valve mid- suppression (with a large 'last output' dump) before T1 could do its job. The valve now counts delivered lines only; while delivery is paused the breaker and the file cap own the kill decision. Adds regression test and README note. 72/72 tests. |
||
|---|---|---|
| extensions | ||
| tests | ||
| .envrc | ||
| .gitignore | ||
| LICENSE | ||
| package.json | ||
| README.md | ||
| shell.nix | ||
| tsconfig.json | ||
pi-monitor
Minimal event-driven monitors for pi. Two tools, no timeouts: a monitor runs until stopped or the session ends, and its stdout lines wake the session only when something happens.
Tools
| Tool | Params | Behavior |
|---|---|---|
monitor_start |
command, maxLinesPerMinute? |
Spawns the command in a background shell; each stdout line is an event. |
monitor_stop |
id (mon-N or all) |
Stops one monitor or all. |
There is no list tool: every start/stop result and every stop notice ends with
an inventory snapshot (Active monitors (N): …) so the agent always knows what
is running.
Output files
Every monitor writes its complete stdout stream to
${XDG_RUNTIME_DIR:-tmpdir}/pi-monitor/<id>.<ts>.out.log (stderr goes to a
sibling <id>.<ts>.stderr.log, capped at 1 MB). The file is never annotated —
every byte is the command's real output. The transcript only ever sees bounded
slices of it; the file holds everything. Files are kept after the monitor stops
(runtime files are ephemeral across reboots; system tmp cleaners handle disk).
Event semantics
- Lines arriving within 200 ms are batched into one wakeup (steer + turn trigger).
- Each wakeup quotes output with
|and labels it untrusted external data. - Lines cap at 4,000 chars; batches at 20,000 chars; unterminated stdout buffers flush (truncated) past 8,000 chars.
- Only
runningmonitors wake the session;stoppingis already silent. - Auto-stops (valves, process exit, spawn error) notify with the reason, the last output, the stderr tail (if non-empty), the output-file path, and the inventory — and they wake the session. Tool-initiated and lifecycle stops are silent: the tool result or lifecycle context already covers them.
Overload tiers
- Batch + caps (always): 200 ms batching, per-line and per-batch caps.
- Suppression (soft): a sliding 10 s window over 24,000 chars pauses
inline delivery — events keep flowing into the output file. A thin
control notice (rate-limited to one per 60 s) reports the suppressed
count, a sample line, the line range, and
sed -n 'a,bp' <file>to extract exactly what was held back. Delivery auto-resumes when the output calms down. - Circuit breaker (hard): 3 suppression spans within 10 minutes or 500 kB suppressed in that window kills the monitor with a notice.
- File cap (hard): an output file over 8 MB kills the monitor.
maxLinesPerMinute(default 60, max 1000) stays as the explicit agent-set valve on delivered lines: exceeded → immediate stop, overflowing batch shown. While suppression is active the valve freezes — the breaker and the file cap own the kill decision until delivery resumes.
Bash guard
A tool_call handler blocks bash commands that would block the turn:
tail -f/-F/--follow, watch, unbounded while/for ((;;)) loops with
sleep, journalctl -f, container logs/events -f, kubectl … -w/--watch,
gh … watch, and bare fswatch/inotifywait. timeout-wrapped commands are
allowed; bounded retry loops are allowed.
Blocked commands receive a hint to use monitor_start instead. If blocking is
intentional, retry the exact command with a # blocking-ok comment appended —
the sentinel is visible in the transcript and approval flow. The guard is a
speed bump with signage, not a security boundary.
Orphan detection
Each start/stop appends a pi-monitor:lifecycle custom entry to the session
(pid, pgid, command, log paths, /proc start time). On session_start the
extension reconciles those records across the whole session tree with live
processes: started-without-stop or shutdown-marked records whose process is
still alive are reported as orphans from a previous pi process, with the
kill -- -pgid command to reap them. No separate state file — the session
file is the state, and custom entries do not participate in LLM context.
Context tree
Monitors are processes: they follow the session, not the branch. After a
session_tree move with active monitors, a lazy inventory hint is queued
(delivered with the next prompt; costs nothing otherwise).
Lifecycle
- Session switch and fork stop monitors (records closed). Session shutdown marks records orphaned — the detached processes keep running after pi exits, and the next session_start reports them. Extension reload kills leftovers from the previous instance in the same process. Stop is SIGTERM, then SIGKILL after a 3 s grace; lifecycle waits up to 4 s for children.
- A SIGKILLed pi process leaves monitor process groups behind; the orphan report on the next session of the same file is the recovery path.
Security model
- Approval is delegated to the platform
tool_callflow. This extension never prompts; policy plugins decide what may run. - Monitor output is untrusted: quoted, labeled, and bounded before it reaches the model.
- Monitors run with the user's permissions until stopped, so only start commands you would also run in a terminal.
- Runtime files live in a 0700 directory under the user's runtime dir.
Development
npm test # node --test over extensions with fake children
pi -e . # load the extension in pi from this directory
pi -ne -e . --print hi # clean load check (installed packages disabled)
The extension ships as TypeScript with explicit .ts relative imports; pi
loads it through its own TypeScript loader. Peer dependencies
(@earendil-works/pi-coding-agent, typebox) are supplied by pi.