{{ message }}
Repository navigation
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.
Contributor
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.
fl4p
force-pushed
the
monitor-pr
branch
2 times, most recently
from
June 25, 2026 13:04
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.
1 task done
Contributor
hunterchristian
added a commit
to chipp-ai/opencode
that referenced
this pull request
Sep 23, 2026
A monitor tool (plus monitor_list/monitor_stop) lets the model watch a long-running background command's output for a pattern, or until it exits, and get notified back into the session as new messages -- a standing watch, not just a one-shot check. Analogous to Claude Code's own Monitor tool. Gated behind OPENCODE_EXPERIMENTAL_MONITOR, matching the existing experimentalBackgroundSubagents convention. Ported from upstream anomalyco/opencode PR anomalyco#33806 "feat: add Monitor tool" (1,398 lines, closed by the automated PR-cleanup bot, never reviewed) -- built directly on this fork's existing BackgroundJob primitive rather than modifying it. Reused nearly word-for-word: the re-arm/replace parameter shape, session-scoped list/stop logic, the untrusted-output fence with an unpredictable id so watched output can't forge its own closing tag, control-character stripping, the never-grant-"always"-for-a-glob-command permission rule, the detached-process-plus-parent-death-watchdog design, the flood guard, and most of the tool description and test scenarios. Rewritten rather than ported: avoided anomalyco#33806's own change to core BackgroundJob (a WakeScope to dodge a self-re-arm deadlock) by forking notifications into the tool's own scope instead, the same pattern task.ts already uses; its hand-written reader/debounce became an Effect Stream pipeline; its base64 command-wrapper isn't needed since this repo's bash tool already runs commands through plain ChildProcess.make; permission goes through the existing bash permission rather than a new one, so existing bash rules govern monitors too. Also considered anomalyco#44191 (cron scheduler, closed by the same bot the same week, praised by a human reviewer first) and the anomalyco#47166/anomalyco#47193/anomalyco#47284 durable-heartbeat resubmits (each closed within hours by a different, 2-hour PR-template-compliance bot, never reviewed) -- neither ported; see known gaps. Found and fixed a real, generalizable bug along the way: a command that writes output continuously with no pause (e.g. `yes`) hung teardown and starved timers, because the stdout read pipeline never yielded control back to the scheduler while data kept arriving. Fixed with a single Stream.tap(() => Effect.yieldNow), documented inline -- the same issue could affect other handle.stdout readers facing a non-pausing producer. Known gaps (deliberately scoped): not durable, in-memory only; no scheduler (only a shell poll-loop inside the watched command); opencode run's non-interactive mode exits once idle, killing active monitors; no UI (model-only via monitor_list); stdout only, 4000-char line cap, 5000-line flood guard; parent-death watchdog is POSIX-only; no external/webhook delivery. New packages/opencode/src/tool/monitor.ts (~360 lines) + monitor.txt. New packages/opencode/test/tool/monitor.test.ts (13 tests: self-cancel re-arm without deadlock, concurrency, list/stop, byte-flood guard, multi-line commands) -- passes 3 runs in a row. Verified against this fork's current dev (not just the agent's own stale worktree base): bun typecheck clean; full packages/opencode suite 3600 pass, only the 4 pre-existing, confirmed-unrelated cf-ai-gateway failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3 of 6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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