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

feat(opencode): add Monitor tool (per-line background watcher)#33806
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

The following comment was made by an LLM, it may be inaccurate:

Related PRs Found

I found two related PRs that should be reviewed alongside PR #33806:

  1. PR feat(opencode): run bash commands in background #33310 - feat(opencode): run bash commands in background

  2. PR feat(shell): add managed background mode #32675 - feat(shell): add managed background mode

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.

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

Copy link
Copy Markdown
Contributor

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:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

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.

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