feat(opencode): add Monitor tool (per-line background watcher) - #33806
feat(opencode): add Monitor tool (per-line background watcher)#33806fl4p wants to merge 5 commits into
Conversation
Adds an experimental `monitor` tool: run a long-lived shell command in the background and wake the model on EVERY new stdout line (one synthetic message per line), until the process exits or the session ends. This fills the gap left by the background-bash PRs (anomalyco#33310, anomalyco#32675), which notify once on completion / are poll-based — none stream per-line events. Built on the existing BackgroundJob primitive (same one the task tool uses): the tool registers a job whose run Effect spawns the process and injects a prompt per line; process lifecycle = job scope (re-arm/session-teardown cancels it). A small plain-function liveness shim keeps the non-interactive CLI alive while a monitor runs and reaps PIDs on SIGINT. Gated behind OPENCODE_EXPERIMENTAL_MONITOR.
|
The following comment was made by an LLM, it may be inaccurate: Related PRs FoundI found two related PRs that should be reviewed alongside PR #33806:
These are not duplicates but rather complementary implementations with different approaches to background execution. The new Monitor tool (#33806) explicitly differentiates itself by streaming one message per stdout line, whereas these PRs use completion-based or poll-based patterns. |
The reader fiber awaited ops.prompt (which awaits the model turn) per stdout line. Cancelling/re-arming a monitor Scope.close's that reader from inside the turn it is awaiting -> circular wait, the monitor tool call hangs forever and wedges the session. Fork the injection (forkDetach) so the reader stays interruptible and Scope.close never blocks on it.
Three safety guards (parity with codex's monitor reference, anomalyco#29922): - Batch stdout lines arriving within ~200ms into one wake, instead of one model turn per line — bounds wake frequency for chatty/line-buffered watchers. - Flood cap: kill a runaway watcher after 5000 lines (flush + notify + scope-kill) rather than flood the session. - Wrap watched output in <monitor_output> markers and label it UNTRUSTED, so a log line like 'ignore previous instructions' can't be mistaken for the user. Deferred (documented): refusing idle-start when user input is queued needs CLI prompt-queue / run-admission integration not reachable from the core tool.
577a5a4 to
497e133
Compare
… + monitor_stop/_list
The Monitor tool wakes the model by injecting a synthetic turn (ops.prompt) per
batch of watched-process output. Re-arming a monitor from inside such a wake (the
exact thing the 'Monitor exited' note invites) cancelled the wake's own job from
inside its own run fiber -> Scope.close interrupts+awaits the fiber issuing the
cancel -> self-join deadlock (sessions wedged ~40min).
Fix: fork model wakes into the BackgroundJob registry (session-lifetime) scope
instead of the per-job scope, so a job cancel can't interrupt a wake that is
running the re-arm turn. Exit notes go through the same forked path (onExit) rather
than an inline Effect.tap, closing the exit-then-rearm variant.
Concurrency: distinct descriptions run as distinct, concurrent monitors (watch a
local file AND a remote/SSH log at once); re-arming the SAME description replaces
only that watch.
Also hardens the wake/stream path:
- WakeScope provided to both start() and extend()
- jobClosing guard so no stale wake is forked after teardown
- byte/char cap on the no-newline carry buffer (line flood guard can't catch it)
- handle.kill({forceKillAfter}) TERM->SIGKILL so a TERM-ignoring child can't hang teardown
- session count via acquireRelease (no leak if interrupted mid-arm)
- stderr:'ignore' so an undrained stderr pipe can't deadlock the watched process
- one persistent streaming TextDecoder (per-chunk decode corrupted split UTF-8)
monitor_stop / monitor_list: with concurrent monitors there was no way to retire or
enumerate one — a wrong-path `tail -F` retries forever (never exits) and a "corrected"
re-arm that REWORDED the description created a second concurrent monitor, leaking the
original.
- monitor_stop (gated by experimentalMonitor): stop a running monitor by id (returned
at arm) or exact description; session-scoped, only running monitors.
- monitor_list (gated by experimentalMonitor): read-only enumeration of the running
monitors in this session (id, description, age, oldest first) so the model can find a
stale/duplicate watch and stop the right one.
- monitor.txt: to REPLACE keep the SAME description; to STOP use monitor_stop; to SEE
what's running use monitor_list; tail -F never exits on a missing path. Arm output
reports the id and the replace-vs-new rule.
Regression tests: self-cancel (asserts prior monitor actually cancelled), exit-then-
rearm, no-newline byte flood, concurrent distinct-description monitors, monitor_stop
(stop by description and by id leaves others running; missing id no-op), and monitor_list
(empty case + enumerates two distinct monitors with ids/descriptions).
…vive Shell.args eval+JSON
Shell.args wraps the command as `eval ${JSON.stringify(command)}` inside zsh/bash -lc.
A real newline survived JSON.stringify as a literal \n escape; inside the eval double-quoted
arg that is backslash-n (not a newline) and eval re-parsed it as an escaped n, fusing lines
(done -> don) so any multi-line command (a while-loop or a python3 -c heredoc) died with a
parse error the instant it armed — firing the "Monitor exited" note and a re-arm thrash loop.
Fix: base64-encode the command on the Node side (unwrapped -> single line of [A-Za-z0-9+/=],
no newline/metachar, passes the eval+JSON layer verbatim) and decode it in the child by piping
into a fresh shell: printf %s <b64> | base64 -d | <shell>. Pipe form, not eval "$(...)", because
the outer eval would command-substitute a $(...) before eval runs and re-corrupt the newlines.
Adds a regression test arming a multi-line while-loop with an embedded python3 -c heredoc;
it times out without the fix and passes with it.
|
Automated PR Cleanup Thank you for contributing to opencode. Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions. This PR was closed because it matched the following cleanup criteria:
PRs created within the last month are not affected by this cleanup. If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate. Thanks again for taking the time to contribute. |
Issue for this PR
Closes #
No linked issue — this adds a new capability.
Type of change
What does this PR do?
Adds an experimental
monitortool: it runs a shell command in the background and sends one message back to the model per new stdout line, until the command exits or the session ends. The point is to watch a log/file/process and react to each event without polling.It reuses the existing
BackgroundJobservice (the same primitive thetasktool uses) rather than adding a new core service: the tool starts a job whoseruneffect spawns the command (scoped) and injects a synthetic prompt for each stdout line; a clean exit injects one final note. Re-arming or session teardown cancels the job, and the process is killed via the job's scope. A small plain-function liveness helper lets the non-interactiveopencode runloop stay alive while a monitor is running and kill the child PID on SIGINT.This is deliberately different from the background-bash PRs: #33310 notifies once on completion and #32675 is poll-based — neither streams per-line events. Gated behind
OPENCODE_EXPERIMENTAL_MONITOR.How did you verify your code works?
cd packages/opencode && bun run typecheckcd packages/opencode && bun test test/tool/monitor.test.ts test/tool/registry.test.ts(green)OPENCODE_EXPERIMENTAL_MONITOR=trueagainst a real model: confirmed a message arrives per stdout line and a single exit note at the end.Screenshots / recordings
N/A — not a UI change.
Checklist