← Back to projects

monitor-extension — Bridging External Events Into an AI Agent Loop

prototype

An 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.

  1. Step 1
    Spawn 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.

  2. Step 2
    Turn 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.

  3. Step 3
    Fail 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.

The delivery path that lets a 2 a.m. test failure become an agent turn without corrupting whatever the agent was doing at 2 a.m.

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

agent waits to be prompted external events arrive as turns

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 on session_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.

Stack
TypeScriptpi (agent runtime)Node.jsPython