feat(opencode): add Monitor tool (per-line background watcher) by fl4p · Pull Request #33806 · anomalyco/opencode · GitHub
Skip to content

feat(opencode): add Monitor tool (per-line background watcher) - #33806

Closed
fl4p wants to merge 5 commits into
anomalyco:devfrom
fl4p:monitor-pr
Closed

fl4p wants to merge 5 commits into
anomalyco:devfrom
fl4p:monitor-pr

Conversation

@fl4p

@fl4p fl4p commented Jun 25, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #

No linked issue — this adds a new capability.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Adds an experimental monitor tool: 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 BackgroundJob service (the same primitive the task tool uses) rather than adding a new core service: the tool starts a job whose run effect 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-interactive opencode run loop 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 typecheck
  • cd packages/opencode && bun test test/tool/monitor.test.ts test/tool/registry.test.ts (green)
  • Ran it live with OPENCODE_EXPERIMENTAL_MONITOR=true against 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

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

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.
@github-actions

Copy link
Copy Markdown
Contributor

fl4p added 2 commits June 25, 2026 08:21
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
fl4p force-pushed the monitor-pr branch 2 times, most recently from 577a5a4 to 497e133 Compare June 25, 2026 13:04
… + 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.
@dtinth dtinth mentioned this pull request Jul 10, 2026
1 task done
@github-actions

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot closed this Jul 25, 2026
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>
@Eslamasabry Eslamasabry mentioned this pull request Oct 1, 2026
3 of 6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant