monitor-extension — Bridging External Events Into an AI Agent Loop
prototypeAn AI coding agent has exactly zero channels through which the outside world can reach it: every turn it takes begins with a human typing, so a test-watcher that catches a regression at 2 a.m. has no way to say so.
The problem
The agent loop is strictly request-response. A background process can detect a failing test, a new error in a log, or a file changing on disk — and then it can do nothing with that knowledge except write it somewhere a human will eventually look. The agent is idle and capable, the event is urgent and known, and there is no wire between them.
Why it mattered
Every asynchronous signal in a development workflow ends up queued behind a human's attention. That is fine when the human is present and expensive when they are not. Worse, the naive fix — letting an external process interrupt the agent mid-turn — corrupts an in-flight tool call and produces a session that fails in ways nobody can reproduce.
What I built
A pi extension that registers a monitor tool. Call monitor(command="python3 watch_bugs.py") and it spawns that script as a long-lived child process with a piped stdout. A non-blocking reader takes each newline-delimited event the script prints and injects it into the conversation as a new turn via sendUserMessage(..., { deliverAs: "followUp" }) — delivered immediately when the agent is idle, queued cleanly when it is mid-run. The watched script is yours; anything that prints lines to stdout is a valid event source. 451 lines, zero runtime dependencies, and a worked example watcher in the repo.
-
Step 1Spawn and watch
The monitor tool spawns your script as a long-lived child with a piped stdout. On a fresh session start — and only a fresh one, never a resume, fork, or reload — a configured AUTO_START command launches without being asked.
-
Step 2Turn a line into a turn
A non-blocking reader consumes stdout line by line and injects each one through sendUserMessage as a follow-up. Idle agent means immediate delivery; a busy agent means the event queues and lands at the next turn boundary, intact.
-
Step 3Fail loudly, clean up completely
A crashed child restarts on exponential backoff capped at 30 seconds. Five consecutive crashes with no output and the monitor is marked down and reported to the agent. On shutdown or abort, the child is killed — no orphans.
Key decisions
Queued follow-up delivery over mid-turn interruption. The whole point is to inject a
turn from outside, and the tempting implementation is to inject it the moment it arrives.
That is a race: an event landing while the agent is halfway through a tool call corrupts
the turn in progress. Using pi's deliverAs: "followUp" channel means an event arriving
mid-run is queued and delivered at the next turn boundary, and an event arriving at
idle is delivered immediately. The agent never observes a torn state, and no event is lost.
Auto-start on startup only — never on resume, fork, or reload. session_start fires
for four different reasons. Spawning the watcher on all of them means a /reload during
development leaves two watchers running, then three, each duplicating every event into the
conversation. Gating on reason === "startup" was a deliberate narrowing, and it is the
difference between a tool that survives an editing session and one that floods it.
A crash ceiling instead of an infinite restart. A script that crashes on launch and restarts forever is a denial-of-service against your own agent loop. The child restarts on exponential backoff capped at 30 seconds; after 5 consecutive crashes producing no output, the extension marks the monitor down and tells the agent rather than retrying in silence. Failing loudly beats failing forever.
Outcome
This is a prototype — a working mechanism, not a measured improvement. Its verifiable properties:
- 451 lines, zero runtime dependencies, plus a worked example watcher (
examples/watch_bugs.py). - 1 new tool (
monitor) turns an arbitrary long-lived script into an event source for the agent. - Exponential backoff capped at 30 s, with a 5-consecutive-crash ceiling after which the monitor is marked down and the agent is notified.
- Four lifecycle hooks handled — spawn on
session_start(startup only), stream on stdout, kill onsession_shutdown, kill on the tool's abort signal. No orphaned child processes. - Delivery is non-blocking: immediate at idle, queued mid-run, never interrupting an in-flight turn.
What a production version needs
There is no backpressure. A watched script that prints a thousand lines a second will inject a thousand turns into the conversation and exhaust the context window. Nothing rate-limits, batches, or deduplicates events — the design assumes a script that speaks rarely and meaningfully. A real version needs a coalescing window and a queue depth cap.
The crash ceiling only counts silent crashes. MAX_CRASHES trips after five consecutive
failures that produced no output. A script that prints one line and then crashes, forever,
resets the counter every time and restarts indefinitely.
It runs an arbitrary command by design. monitor(command=...) spawns whatever it is given,
and AUTO_START does so without a prompt. That is the feature, and it is also the reason this
ships project-local rather than as a global extension — the watched script should be a file you
can see in the repo you are working in.
There are no tests. TEST_PLAN.md specifies them — fake timers for the backoff logic, a
stubbed extension API, seeded randomness — and none are written. CI runs npm install and
npx tsc --noEmit.