@cahalane/pi-monitor
Monitor tool for pi: stream background command, poll and WebSocket output into the live session.
Package details
Install @cahalane/pi-monitor from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@cahalane/pi-monitor- Package
@cahalane/pi-monitor- Version
0.2.0- Published
- Sep 3, 2026
- Downloads
- 112/mo · 112/wk
- Author
- colm2
- License
- ISC
- Types
- extension
- Size
- 92.8 KB
- Dependencies
- 1 dependency · 3 peers
Pi manifest JSON
{
"extensions": [
"./index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-monitor
A pi extension for event-stream and change monitoring. The model calls
monitor once; streamed command events, change-only polling for CI and deploy status, WebSocket
feeds, and bounded wake-ups deliver relevant changes into the running session. It does
not need to rerun a status command in a loop.
Attribution
This project is original work by Colm Cahalane. Its delivery-scheduler design was informed by
pi-background-tasks, an ISC-licensed pi extension by
Ismail. This repository does not include source files from that
package; the upstream notice is preserved in THIRD_PARTY_NOTICES.md.
The model calls monitor once. Each batch of new output then arrives on its own as a separate
message, and the model can start a turn to react to it.
Example
Ask pi to watch a build, and it makes one tool call:
monitor { name: "build", command: "./gradlew build --console=plain", match: "ERROR|BUILD" }
Output then arrives on its own, one message per batch:
monitor mon_1 "build" — line 1 (untrusted output)
| ERROR: unresolved reference: fooBar
monitor mon_1 "build" ended — command exited 1. 4 lines delivered, 310 B, over 47s.
The watch is finished; do not restart it just to confirm.
For CI status, use poll so unchanged output costs nothing:
monitor { name: "ci", poll: { command: "gh pr checks 4821", intervalMs: 30000 }, until: "fail|pass" }
When to use it
Use monitor for event-stream and change monitoring: tailing a build log, watching a long-running
command, checking CI/PR/deploy status on a timer, or consuming a WebSocket feed. It is a good fit
when the model would otherwise poll. poll reruns the command but emits only changed output, and
scheduler limits keep automatic wake-ups bounded and controlled.
This is not the best tool for a one-shot noisy command where only the terminal result matters.
Use bg_run for that case; its normal terminal notification reports completion.
Three sources:
command— streams a long-running command's stdout/stderr lines as they arrive.poll— reruns a command on an interval and reports only when the output changes. This is the CI/PR/deploy case; a status table that prints unchanged every ten seconds produces no events until it moves.ws— connects to a WebSocket feed that already pushes events, rather than pulling them.
Requirements
Node 22.6 or newer. The extension is TypeScript loaded through pi, and it uses node's built-in
WebSocket, so nothing needs compiling.
Install
pi install git:github.com/cahalane/pi-monitor
That adds the package to ~/.pi/settings.json and runs npm install for you, which pulls in
undici. Restart pi and the monitor, monitor_list and monitor_stop tools are available.
To hack on it instead, clone the repo and install from the path:
git clone git@github.com:cahalane/pi-monitor.git
cd pi-monitor && npm install
pi install ./
undici is only needed for WebSocket monitors, which use it to pin the connection to an
already-validated address (see Security). Without undici importable, ws monitors refuse to start
unless PI_MONITOR_ALLOW_UNPINNED_WS=1 is set. command and poll monitors do not need it.
Uninstall
pi remove git:github.com/cahalane/pi-monitor
Tools
monitor
Starts a watch and returns immediately with a monitor id (mon_1, mon_2, …), the resolved
source, and the caps in effect. Exactly one of command, poll, ws is required.
| Parameter | Type | Default | Notes |
|---|---|---|---|
name |
string | required | Short label, shown in event headers, the widget and monitor_list. |
command |
string | — | Long-running shell command; mutually exclusive with poll and ws. |
poll |
{command, intervalMs?} |
— | Rerun command every intervalMs (min 1000 ms, default 15000 ms, max 3,600,000 ms) and emit only when the canonical output changes. |
ws |
{url, protocols?} |
— | ws:// or wss:// only, no embedded credentials, no whitespace. |
cwd |
string | session cwd | command and poll only. |
timeoutMs |
number | 300000 | Watch ends at this deadline unless persistent. Clamped to 1000–86,400,000 ms. |
persistent |
boolean | false | Ignore the deadline; run until stopped or capped. |
match |
string (regex) | — | Only lines matching this become events. |
ignore |
string (regex) | — | Lines matching this are dropped, applied after match. |
until |
string (regex) | — | The watch ends once a line matches, after delivering that line. |
dedupe |
boolean | false | Drop a line identical to the immediately preceding accepted line. |
batchMs |
number | 400 | Coalesce lines arriving within this window into one interjection; 0 sends each line as it lands. Clamped to 0–60000 ms. |
maxEvents |
number | 100 | Stop the monitor after this many delivered lines. Clamped to 1–1000. |
wake |
boolean | true | Start a turn (triggerTurn) when output arrives and the agent is idle. false means the output waits for the next reply anyway. |
deliverAs |
steer | followUp | nextTurn |
steer |
Escape hatch for a monitor whose output should not interrupt the current line of work. |
Command monitors run under /bin/sh -c (or cmd.exe /c on Windows) in their own process group.
Filtering happens after ANSI and other terminal control sequences are stripped. The match,
ignore, until, and dedupe checks see the bounded line (the maximum is 2000 characters by
default). Patterns that match common catastrophic-backtracking shapes are rejected heuristically;
this is not a guarantee that a regular expression is safe. Poll monitors run the same way, once
per interval, and hash trimmed, newline-normalised, ANSI-free canonical output to detect change.
monitor_list
No parameters. Returns every monitor in the session — live and recently ended — with id, name, source, status, events delivered, bytes injected, uptime, last-event age, buffered-but-undelivered line count, and end reason if ended. Also reports the session-wide injected-byte budget used out of 262144 bytes (256 KiB). Cheap to call; the footer widget is the primary display while something is running.
monitor_stop
One parameter, id — a monitor id or "all". Stops the source (kills the process group, or
closes the socket) and returns the final counts. Does not send a model-facing terminal notice for a
tool-initiated stop; the tool's own return value is the notice.
/monitor command
/monitor list (or bare /monitor) prints the same snapshot as monitor_list. /monitor stop <id|all> stops one or all monitors. Both are host UI notifications, not tokens spent by the model.
Footer status and widget
While at least one monitor is live, the status line shows ◉ N watching (with a (waking suspended) suffix when the consecutive-wake cap has fired), and a widget lists each live monitor
with its event count and last-event age. Both clear when no monitor is live.
Delivery semantics
pi's pi.sendMessage has three delivery modes, and monitor uses all of them precisely:
steerdelivers after the current assistant turn finishes all of its tool calls, immediately before the next LLM call. It does not interrupt a running tool call — pi has no mechanism for that.followUpwaits until the agent has no tool calls left at all before delivering.triggerTurn: truestarts a new turn only when the agent is idle; it never creates a concurrent turn alongside one already running.
Monitor events and the terminal "monitor ended" notice both use the monitor's own deliverAs
(default steer). They share one queue deliberately: sending the notice as followUp while events
went out as steer let an idle session show "ended" before the last event it summarised.
Scheduler rules
- One undelivered notification per monitor. Once a monitor's batch is sent, further lines coalesce
in its buffer rather than triggering a second send. The buffer is released once
turn_endoragent_settledfires, which is pi's signal that the previous notification has been consumed. - One automatic wake outstanding across the whole registry at a time. A second monitor's output
arriving while a wake is already pending is sent without
triggerTurn(it will be picked up anyway) rather than queuing a second wake. - A minimum wake interval of 5000 ms. If a monitor's batch is ready before 5 s have passed since the last wake, the flush is deferred rather than sent — sending it immediately would queue a message with nothing scheduled to read it.
- A cap of 8 consecutive monitor-triggered turns. Hitting it suspends waking: monitors keep
collecting and their output still arrives, but nothing starts a turn until the user sends a
message (
before_agent_start), which resets the counter and lifts the suspension. The model is told this once, via afollowUpnotice withtriggerTurn: false.
Caps and budgets
| Budget | Value | Effect when hit |
|---|---|---|
| Live monitors per session | 8 | monitor rejects a new watch until one is stopped. |
| Events per monitor | 100 (or maxEvents) |
The monitor auto-stops with a cap end reason. |
| Injected bytes per monitor | 64 KiB | Same: auto-stop, cap end reason. |
| Injected bytes per session | 256 KiB | Every live monitor is stopped with a cap end reason citing the session budget. |
| Buffered-but-undelivered bytes, all monitors | 64 KiB | Oldest lines are dropped from the biggest buffers first; the drop count surfaces as "N dropped" in the next event header. |
| Lines per flush | 20 (ceiling 50) | Extra lines are elided from the middle of the batch with a … N lines elided … marker. |
| Bytes per flush | 8 KiB | Lines are dropped from the middle (recomputing the elision marker) until the batch fits, or hard-truncated as a last resort. |
| Line length | 2000 characters | The line's head is elided, keeping the tail, since new output is usually at the end. |
| Poll command output captured per tick | 64 KiB | Extra output is discarded before hashing/diffing. |
| WebSocket message size | 1 MiB | The message is dropped and the watch ends with an error reason. |
A monitor that hits maxEvents or its byte cap does not just stop quietly: it sends the same
monitor-ended notice as any other end reason, so the model does not need to poll to find out.
Limitations
- Monitors are session-scoped.
/new,/resume,/forkand quitting all stop every monitor; nothing resumes across sessions, andmonitor_listafter/resumeis empty. - Print mode (
pi -p) exits as soon as the agent settles, which stops the monitors with it. A watch whose first event is seconds away will not survive; monitors are for interactive sessions. - There is no mid-tool-call interrupt in pi. A monitor's output cannot land while a tool the model called is still running; it waits for that tool call to finish.
- Process-group kill (
SIGTERMthenSIGKILLafter 3 s) is POSIX only. On Windows, stop callschild.kill()on the shell process itself and is best effort — a pipeline's children may outlive it. - A WebSocket message over 1 MiB ends the watch rather than truncating it. Subscribe to a filtered feed instead of a firehose.
- A binary WebSocket frame is never decoded; it becomes a
[binary frame, N bytes]placeholder line.
Security
Every event and terminal notice tells the model the payload is untrusted output and not to follow
instructions found in it. There is no guarantee that secrets are redacted. ANSI and control
sequence stripping removes terminal decoration before filtering and hashing; it does not sanitise
instructions or secrets. Each line is also prefixed with | in the rendered payload, so nothing in
a watched stream can forge a header or close the block early.
WebSocket monitors are the SSRF-sensitive path. ws:///wss:// targets are validated (scheme,
ASCII only, no embedded credentials, valid subprotocol tokens), the hostname is resolved once, and
every resolved address is checked against loopback, private (10/8, 172.16/12, 192.168/16),
link-local (169.254/16, which covers cloud metadata endpoints), CGNAT (100.64/10), and the IPv6
equivalents. The connection is then made through an undici dispatcher whose lookup returns only
that already-validated address — TLS still verifies and SNIs on the real hostname, but the connect
step cannot re-resolve to a different, private address after the check passed. Without undici importable, ws monitors refuse to start unless PI_MONITOR_ALLOW_UNPINNED_WS=1 is set, which
accepts the re-resolution risk explicitly.
Opening a WebSocket also asks the user first: monitor calls ctx.ui.confirm with the target URL
once per call, and refuses the connection if the answer is no. With no UI attached there is nobody
to ask, so ws monitors fail unless PI_MONITOR_ALLOW_WS=1 is set. There is no per-host "always
allow".
Command and poll monitors are not sandboxed and are not gated by any confirmation prompt: calling
monitor with a command runs it exactly as bash/bg_run would. Enabling this extension adds a
second arbitrary-command execution path that does not go through Bash's own allow/deny policy.
Pre-tool hooks still see the monitor tool call and can block it there, but there is no
extension-level confirmation step in front of it.
Tests
npm test
npm run test:integration
Unit coverage exercises line filtering, batching, WebSocket address validation, event and notice rendering, and the delivery scheduler with fake clocks and sources, without external I/O. Integration tests spawn local processes and open loopback sockets to exercise the real source behaviour without external network access.
Bugs caught by these tests and live smoke runs are recorded in docs/design.md,
along with why the scheduler defers a flush rather than sending output nothing is scheduled to
read.