Feature: an agent-callable `monitor` tool that wakes Codex on background events (logs, files, builds, CI) without polling
What variant of Codex are you using?
CLI and the Desktop app. The capability lives in codex-core, so it lands once and works on every surface (CLI, app, IDE extension).
What feature would you like to see?
The gap
Codex is turn-driven, so between turns it sits idle and cannot react to anything happening in the background. When I want the agent to notice that a build finished, that a log just printed an error, that a file changed, or that a CI run flipped state, I have two options today and both are bad.
Polling is the expensive one. #13733 traced where the cost comes from: each background status check runs a full turn that re-sends the entire conversation history, so a long cargo build watched in a poll loop costs tokens proportional to history size times poll count while no real work happens. Blocking the turn is worse for anything that never exits on its own, like a file watcher.
What is missing is a way for the agent to start watching something, go fully idle, and be woken only when the thing it cares about actually emits an event.
The proposal
Add an agent-callable monitor tool. The model starts a watch on an ordinary shell command, and each line that command prints (stdout or stderr) is delivered back as a notification that wakes the idle session and begins a new turn. The watch keeps running in the background until the command exits or the model stops it.
monitor(action="start", command="fswatch ~/project/src", description="src changes")
-> "Started monitor mon_ab12: watching \"src changes\". Stop it with action=stop, id=mon_ab12."
monitor(action="list") -> active watches
monitor(action="stop", id="mon_ab12")
This is the shape Claude Code ships as its Monitor tool. The command is the watcher, the lines it prints are the wake events, and the model filters to what it cares about (for example tail -F app.log | grep --line-buffered ERROR; pipe-filtering a tool that writes to stderr needs 2>&1 first, though the watch wakes on stderr either way). While the watch is quiet the session makes no API calls and spends no tokens, the property #28144 asks for directly.
How this differs from #20312
#20312 asks for the same capability framed as a config-declared inbound event source: the user lists sources in config.toml, Codex keeps a durable resumable cursor over each, and their events wake the session. That is inbound plumbing the user sets up once and that persists. This proposal is a different mechanism: a within-session tool the model reaches for mid-task and drops when done. There is no config registry and no persisted cursor, by design, because the watch is ephemeral and torn down when the session ends.
So the two differ on more than an entry point:
| | #20312 (config source) | this proposal (agent tool) |
|---|---|---|
| Control | the user configures durable sources | the model starts ephemeral watches mid-task |
| State | a persisted, resumable cursor per source | none; the watch lives and dies with the session |
A maintainer could ship either without the other, and a config-declared source could sit on top of this tool. The implementations discussed on #20312 (an MCP-notification ingress path, a message-bus reference) are solving the durable-source problem; this is the in-session-capability half. I would rather fold the design in wherever it is most useful than fragment the discussion, but the agent-callable form is a distinct enough decision that it seemed worth its own thread.
Use cases
The ones a config-declared source cannot express, because the model decides them mid-task:
- Watch this file while I work on that one:
fswatch <path>orinotifywait -m <path>to react to an edit or a new file during a refactor, instead of re-reading on a timer. - Watch the build I just started: go idle, wake when it finishes or prints an error, without blocking other work (#2062, #15723).
- Watch this log for the failure I expect:
tail -F <log> | grep --line-buffered <pattern>to wake on the first matching line. - Watch this CI run: a short poll loop as the watched command, so the agent is woken when the run flips state.
And the ones that overlap #20312's territory, for completeness:
- Queue and bus consumers: tail an events stream for multi-agent coordination.
- Long-running jobs: notice a training run that started crash-looping, or a deploy that flipped state.
Related issues
The same gap surfaces across the tracker from several angles. One monitor tool in core covers the common core of these:
- #28144: wait/wake for goals without spending tokens. It reaches the same conclusion from the goal-runtime side (let a goal wait on an event without burning turns); this tool is one concrete event source such a goal could wake on.
- #13733: background-process polling spends a full API turn, with the whole history, per check. The cost analysis above is its root cause.
- #20312: native event-driven session wake primitive (the config-declared framing, discussed above).
- #17737: a Claude-Code-like
/monitoringTUI for background terminals; a surface that could sit on top of a core primitive like this one. - #15723: background subprocesses do not wake the calling agent on completion.
- #2062: monitor background services without blocking.
- #4751: real-time output streaming from long-running commands; this builds on that streaming substrate.
Additional information
I built a working reference implementation and run it in both the CLI and the Desktop app. The notes below are from that build. Full diff (public fork), one commit on top of the v0.142.0 release tag it builds on: https://github.com/openai/codex/compare/rust-v0.142.0...yaanfpv:codex:monitor-tool. It is about 1,100 lines across 19 files, no protocol change, behind a feature flag.
How it fits the architecture
It reuses two pieces Codex already has, which is why it stays small:
- Spawning reuses
unified_exec. The watcher is just a unified_exec process, so it inherits the existing sandbox, approval, environment, and shell handling with no new exec path. It is stored in the shared process manager and reaped at session shutdown like any other. - Waking reuses the session's own idle-start path. Each output line, batched over a short window, is delivered through the same mechanism the session already uses to begin a turn. No new event type, no protocol change.
A small per-session registry tracks active watches so list and stop work, and holds each delivery task as an abort-on-drop handle so stopping a watch (or ending the session) tears its loop down cleanly.
The tool the model sees
name: "monitor"
description: "Run a shell command as a long-lived background watcher. Each line the
command prints (stdout or stderr) is delivered to you as a notification, prefixed
with the label; lines emitted close together are batched. The watch ends when the
command exits. Use it to react to events without polling: fswatch <path> or
inotifywait -m <path> for file changes, tail -F <log> | grep --line-buffered for log signals, or a poll loop for remote state. action=start begins a
<pattern>
watch and returns its id; action=stop ends the watch with that id; action=list
shows active watches."
parameters:
action: "start" | "stop" | "list" (required)
command: the watcher command (required for action=start)
description: label prefixed to notifications (required for action=start)
id: a monitor id from a prior start (required for action=stop)
The wake (the one seam that matters)
Each batch of lines is delivered as a typed contextual fragment, injected into a running turn if one is active, or used to start a turn if the session is idle. That is the whole wake mechanism, and it is why no protocol change is needed:
// A MonitorNotification is a ContextualUserFragment: a user-role message
// wrapped in <monitor_notification> markers, registered alongside the other
// injected-context fragments. The markers keep watched-process output
// recognizable as injected context, never mistaken for the user's own input.
async fn deliver(session: &Weak<Session>, description: &str, body: String) {
let Some(session) = session.upgrade() else { return };
let items = vec![ContextualUserFragment::into(
MonitorNotification::new(description, body),
)];
// Inject into a running turn or wake an idle one; if the idle start loses
// a race or is refused, record the items rather than drop them, so even
// the terminal "watcher exited" notice is never silently lost.
if let Err(items) = session.inject_if_running(items).await
&& let Err(err) = session.try_start_turn_if_idle(items).await
{
session.inject_no_new_turn(err.into_input(), None).await;
}
}
On the idle path, try_start_turn_if_idle already refuses to start work when it should not (queued user input, Plan mode), so a watcher never starts a turn over the user. Injection into an already-running turn is currently unconditional, and that is the one piece I would want to refine in a PR: with several watches live, one watcher's output can land in a turn another woke. Scoping the injection, or holding output until the active turn ends, is a small follow-up; I am flagging it rather than hiding it.
Delivery, batching, back-pressure
The delivery loop subscribes to the process output stream (stdout and stderr, already combined upstream) as a second consumer, splits it into lines, coalesces lines that arrive close together into one notification, and bounds a runaway watcher. The spawn hands back whatever its initial yield already captured, and that seed is delivered first, so the very first lines (an inotifywait banner, a grep that already matches) are not lost in the window before this loop subscribes. Condensed:
const BATCH_WINDOW: Duration = Duration::from_millis(200);
const FLOOD_MAX_LINES: usize = 5000;
// broadcast receiver, capacity 64; a second consumer, the original stream untouched
extend_lines(seed); // deliver the pre-subscription output first
loop {
tokio::select! {
received = rx.recv() => {
let flooded = match received {
Ok(chunk) => extend_lines(chunk), // arms a 200ms flush; true at the ceiling
Err(Lagged(skipped)) => note_drop(skipped), // surfaced + counted, not swallowed
Err(Closed) => break,
};
if flooded { // ceiling enforced per line, mid-chunk
stop_for_flood().await; // flush, notify, kill, deregister
return;
}
}
() = wait_until(flush_at) => flush().await, // coalesced batch -> one wake
() = exit.cancelled() => closing_at = grace(), // start a short trailing-output grace
() = wait_until(closing_at) => break, // ... then exit, so a final chunk still lands
}
}
// drain remaining output, deregister from the registry, deliver "watcher exited"
The registry
pub(crate) struct MonitorManager { monitors: Mutex<HashMap<String, MonitorEntry>> }
// insert(id, process_id, description, command, task) | remove(id) -> process_id
// | deregister_self(id) (self-removal, no abort) | list() | abort_all()
It lives on the session's services. stop removes the entry (dropping its task aborts the loop) and terminates the underlying process; session shutdown calls abort_all and the process manager reaps the processes. When a watched command ends on its own, the delivery loop deregisters its own entry before it announces the exit, so list and stop never report a watcher that is already gone. The self-removal path defuses the entry's abort-on-drop, since that loop is the very task being removed and still has its final "watcher exited" notice to send.
Safety and limits
- The woken turn runs under the same sandbox and approval policy as any other turn. The watched command is a normal sandboxed unified_exec process; nothing new is granted.
- The output channel is bounded (64 chunks). If a watcher outruns it, the gap is surfaced to the model as a "dropped N chunks" notice and counted toward the flood ceiling, never dropped silently.
- Flood guard, by lines and by bytes: a watcher that crosses 5000 lines is auto-stopped with a notice, and a run of 16 KiB with no newline is truncated (which still counts toward that ceiling), so neither a line-flood nor a newline-free stream can spin the session or grow the buffer without bound.
- Notifications are never silently dropped: an idle session is woken, a running turn is injected into, and if the idle start loses a race the items are recorded against the turn rather than discarded. That matters for the terminal "watcher exited" notice, which fires after the loop has already deregistered itself and so cannot be retried.
- It does not ask Codex to host any bus or broker; the watcher is just a process the agent already had the rights to run.
- Edge cases handled: the watch is line-oriented, so a final line with no trailing newline is held until the command exits and then delivered; on exit the loop waits a short grace for any trailing chunk still racing the cancellation token before its final flush (the same grace pattern the codebase already uses for streaming output); the first lines printed before this loop subscribes are recovered from the spawn's captured output, give or take a small snapshot-to-subscribe window; and the flood ceiling is enforced per line, mid-chunk, so one giant chunk cannot blow past it before the check runs.
Tests and verification
The reference includes tests (core/tests/suite/monitor.rs, plus two unit tests in core/src/unified_exec/monitor.rs) covering the wake path and the safety behavior:
- stdout output wakes the agent (the common fswatch / tail case).
- stderr output wakes the agent too (the process stream the monitor reads carries both).
- a line printed before the initial yield ends still arrives, delivered from the seed.
- an unterminated final line is delivered when the watch ends (the line-oriented contract above).
- the flood guard auto-stops a runaway line-flood with a notice.
- a newline-free stream is truncated rather than buffered without bound.
- a command that exits delivers a "watcher exited" notice.
- the registry self-empties after a watch ends, so a later
listreports no active monitors. - unit tests cover the registry (insert / list / remove) and that self-deregister prunes an entry without aborting its task.
The integration tests each exercise the whole path: the tool call, a real background subprocess, the output, the idle-wake, the labelled delivery. The remaining edges I would cover with the PR are the lag back-pressure path under a genuinely lagging producer, the snapshot-to-subscribe seed window, and multi-watcher injection attribution.
The monitor suite passes: eight integration tests plus two unit tests, run with the repo's CI settings. The changed crate is clean under fmt and clippy. It is additive and gated behind a feature flag that defaults off, so it changes nothing when disabled.
Real-world, in production Codex: I built from source and ran it in both the CLI and the Desktop app with a live model. The agent calls monitor(action=start, command="fswatch <folder>", description="..."), finishes its turn, and the session goes idle making no API calls. Touching a file in that folder wakes it on its own and it reports the change. It reproduces in a few steps: enable the feature, prompt "use the monitor tool to watch <folder> and tell me when it changes," then touch a file in the folder.
On a PR
The diff is deliberately small: three new files (the tool handler, the delivery loop, and the typed notification fragment), plus a handful of one-to-nine-line wiring edits, no protocol change. It reuses unified_exec for spawning and the session's idle-start path for waking, so there is no new exec path or event type to reason about, and it is feature-gated, so it can land off by default and be turned on when ready. That should keep it cheap to review.
As per the contributing guidelines, I am limiting this to the proposal, the design, and the working reference rather than opening an unsolicited PR. If the approach is right, I am happy to open one and finalzie the details first (CLA included). I am also happy to split the flood guard or the registry into separate reviewable pieces if that reviews more easily.
6 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Reviewed the flagged #29865. It is the same underlying need (wake Codex on background process output without polling) in the
wake_on_output-flag-on-exec_commandform; the author closed it and folded the work into #22003, the broader "inject background output into the session" issue.Keeping this open as the dedicated-tool form of the capability: a
monitortool the model starts/stops/lists, backed by a per-session registry, rather than a flag on a single exec call or a generic injection path. Same core wake seam, different surface.Cross-linking the cluster for whoever triages: #22003 (the open hub, where this is converging) and #29865 (the flag form, now closed). I have also added the working reference over on #22003.
I'm not sure how much this issue overlaps with my own needs, but it's certainly similar. I have a workspace that interacts with files generated by another program, and I want the agent to react to changes made to those files automatically. However the changes don't come in on any kind of set schedule, and scheduling the agent to check the files on an interval is both too slow (won't react immediately, waits for interval) and wasteful of tokens since the agent can't actually tell if files changed without reading them.
Ideally it should be able to create a file watcher that triggers it to read the files as soon as they change but only when they actually change, and without needing to burn tokens detecting if a file changed when the operating system can do that much cheaper.
Also I think it would be ideal if there was some degree of filtering of what actually wakes the model or not - rather than just filtering what it receives, preventing it from waking at all if the filter is not satisfied. Based on its instructions, the model could write a small program that pragmatically checks output, logs, files, etc. and only wakes up the agent if the program determines that it is needed. This would further reduce the amount of unnecessary token usage and ensure more consistent behavior. Basically, shift as much as possible to a deterministic local process and only engage the agent for things it is actually needed for.
@zeel01 that overlaps almost exactly, and the wake-filtering is the part I care most about too.
The shape I've been building: the monitor runs a command you hand it, and every line that command prints to stdout is a wake event. So the filter isn't a separate config knob, it's just whatever the command emits. For your case that's
fswatch <dir>(orinotifywait -mon Linux) piped through a grep, and the agent only wakes on the lines that survive it. The OS does the change detection, nothing polls, and the model never reads a file just to learn whether it changed.Your "push it to a deterministic local process" instinct composes nicely with that. The watched command can be a small script the model writes itself, one that inspects the logs/files/output and prints a line only when a wake is actually warranted and stays silent otherwise. The model then only gets engaged for things that already cleared a local gate.
I have a working version of this on a fork. Here's the full diff against rust-v0.142.0: https://github.com/openai/codex/compare/rust-v0.142.0...yaanfpv:codex:monitor-tool (agent-callable
monitortool, with fswatch / inotifywait / tail -f / poll-loop backends, persistent or one-shot). It's there as a reference for what an upstream shape could look like, and if you want to try it against your file-watching case I'm happy to walk through the build-and-run steps.I went ahead and implemented this against current
main. The branch is up in my fork with a draft PR, in case it's useful as a reference: https://github.com/hassaans/codex/pull/1Quick summary of the approach: the
monitortool runs a shell command as a background watcher on top of the existingunified_execmachinery, and each line it prints comes back to the model as a notification, with a flood guard for runaway output. The watcher runs with the same sandbox access the session has already granted, so it can reach the same paths as the rest of the turn. There's an end-to-end test suite plus a regression test for the permission behavior.I know external PRs are invitation only, so no expectations here. Happy to keep this as a reference, or turn it into a real PR if the team would find it helpful.
@hassaans Nice. We basically landed on the same design without comparing notes: a
monitortool running a watcher on top ofunified_exec, every line it prints coming back as a notification, a flood guard for runaway output, and the watcher inheriting the session's sandbox so it reaches the same paths the turn already can.I put a working version up a few comments back too: https://github.com/openai/codex/compare/rust-v0.142.0...yaanfpv:codex:monitor-tool. Mine's a bit further along if it's useful to pull from: it also runs in the desktop app, does persistent and one-shot watches, keeps a per-session registry for list/stop, and handles a few things that bit me along the way (trailing output racing the exit, the first slice of output before the watch attaches, and which live turn a watcher's output lands in when several are running). ~1,100 lines, behind a flag, no protocol change.
Either way, two of us building this independently probably says the design's about right. If the team would rather land one PR than two forks, I'll merge ours together.