44 more lines
+ # Each file change is an event
+ inotifywait -m --format '%e %f' /watched/dir
+ # Poll GitHub for new PR comments and emit one line per new comment
+ last=$(date -u +%Y-%m-%dT%H:%M:%SZ)
+ while true; do
+ now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
+ gh api "repos/owner/repo/issues/123/comments?since=$last" --jq '.[] | "\(.user.login): \(.body)"'
+ last=$now; sleep 30
+ done
+ # Node script that emits events as they arrive (e.g. WebSocket listener)
+ node watch-for-events.js
+ # Per-occurrence with a natural end: emit each CI check as it lands, exit when the run completes
+ prev=""
+ while true; do
+ s=$(gh pr checks 123 --json name,bucket)
+ cur=$(jq -r '.[] | select(.bucket!="pending") | "\(.name): \(.bucket)"' <<<"$s" | sort)
+ comm -13 <(echo "$prev") <(echo "$cur")
+ prev=$cur
+ jq -e 'all(.bucket!="pending")' <<<"$s" >/dev/null && break
+ sleep 30
+ done
+**Don't use an unbounded command for a single notification.** `tail -f`, `inotifywait -m`, and `while true` never exit on their own, so the monitor stays armed until timeout even after the event has fired. For "tell me when X is ready," use Bash `run_in_background` with an `until` loop instead (one notification, ends in seconds). Note that `tail -f log | grep -m 1 ...` does *not* fix this: if the log goes quiet after the match, `tail` never receives SIGPIPE and the pipeline hangs anyway.
+**Script quality:**
+- Every pipe stage must flush per line or matches sit in its buffer unseen: `grep` needs `--line-buffered`, `awk` needs `fflush()`. `head` cannot flush at all — `| head -N` delivers nothing until N matches accumulate, then ends the stream.
+- In poll loops, handle transient failures (`curl ... || true`) — one failed request shouldn't kill the monitor.
+- Poll intervals: 30s+ for remote APIs (rate limits), 0.5-1s for local checks.
+- Write a specific `description` — it appears in every notification ("errors in deploy.log" not "watching logs").
+- Only stdout is the event stream. Stderr goes to the output file (readable via Read) but does not trigger notifications — for a command you run directly (e.g. `python train.py 2>&1 | grep --line-buffered ...`), merge stderr with `2>&1` so its failures reach your filter. (No effect on `tail -f` of an existing log — that file only contains what its writer redirected.)
+**Coverage — silence is not success.** When watching a job or process for an outcome, your filter must match every terminal state, not just the happy path. A monitor that greps only for the success marker stays silent through a crashloop, a hung process, or an unexpected exit — and silence looks identical to "still running." Before arming, ask: *if this process crashed right now, would my filter emit anything?* If not, widen it.
+ # Wrong — silent on crash, hang, or any non-success exit
+ tail -f run.log | grep --line-buffered "elapsed_steps="
+ # Right — one alternation covering progress + the failure signatures you'd act on
+ tail -f run.log | grep -E --line-buffered "elapsed_steps=|Traceback|Error|FAILED|assert|Killed|OOM"
+For poll loops checking job state, emit on every terminal status (`succeeded|failed|cancelled|timeout`), not just success. If you cannot confidently enumerate the failure signatures, broaden the grep alternation rather than narrow it — some extra noise is better than missing a crashloop.
+**Output volume**: Every stdout line is a conversation message, so the filter should be selective — but selective means "the lines you'd act on," not "only good news." Never pipe raw logs; filter to exactly the success and failure signals you care about. Monitors that produce too many events are automatically stopped; restart with a tighter filter if this happens.
+Stdout lines within 200ms are batched into a single notification, so multiline output from a single event groups naturally.
+The script runs in the same shell environment as Bash. Exit ends the watch (exit code is reported). Every monitor expires after `timeout_ms` (default 5 minutes, at most 10 minutes): it is killed and you get one notice with the event count. Re-arm it if you still need the watch; for a long watch (PR monitoring, log tails) set `timeout_ms` to the maximum and re-arm on each expiry, and widen the filter if an expiry with no events was unexpected. Use TaskStop to cancel early.
+**ws source** — open a WebSocket and stream each incoming text frame as an event. No shell, no polling: the server pushes, you get notified.
+ Monitor({
+ ws: {url: 'wss://events.example.com/stream', protocols: ['v1']},
+ description: 'deploy events',
+ })
+Each text frame becomes one notification (multiline frames stay as one event). Binary frames are reported as `[binary frame, N bytes]` rather than passed through. Socket close ends the watch with the close code surfaced; errors are surfaced before close. Same rate limiting as bash — a firehose will be suppressed and eventually stopped, so subscribe to a filtered feed where one exists.
+Prefer this over `command: 'websocat wss://…'` — it avoids the extra process and line-buffering pitfalls. Use bash when you need to transform or filter frames with shell tools before they become events.