Persistent Watcher Archetype
BSecurityUse when starting, launching or provisioning a watcher or steward - a coordinator asked to start a watcher on an agent or a roster - and when running as a persistent watcher agent that watches other agents' tmux panes, auto-approves safe modals, warns on context, and escalates up a parent-reference tree. Starting one means running resources/start-watcher.sh, never a hand-built monitor. Distinct from watcher-archetype (the wake-run-exit scan/emit/report substrate) — this archetype stays alive.
- 2 stars
- 0 votes
- 0 copies
- 0 views
- Added November 7, 2026
Works with
Security analysis
81/100- Uses curl or wget to download content
- Performs destructive filesystem operations
Pro scans all 10 files and shows the line behind each finding
npx -y skills add thrum-team/thrum-pro --skill persistent-watcher-archetype --agent claude-codeAre you the author of Persistent Watcher Archetype?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/thrum-team-persistent-watcher-archetype)---
name: persistent-watcher-archetype
description: "Use when starting, launching or provisioning a watcher or steward - a coordinator asked to start a watcher on an agent or a roster - and when running as a persistent watcher agent that watches other agents' tmux panes, auto-approves safe modals, warns on context, and escalates up a parent-reference tree. Starting one means running resources/start-watcher.sh, never a hand-built monitor. Distinct from watcher-archetype (the wake-run-exit scan/emit/report substrate) — this archetype stays alive."
---
# Persistent Watcher Archetype - starting one and running as one
## Starting a watcher or steward
Run `resources/start-watcher.sh`. It is the one supported way to start one: do not hand-build the monitor, write
`watch_params.json` yourself or guess its fields (the schema is `resources/watch-params-schema.md`).
```bash
resources/start-watcher.sh <name> --role watcher|steward --parent <agent|user:NAME> \
--roster <a,b,...|@file> --model <model> --effort <effort> \
[--cadence-active 5m] [--cadence-idle 10m] [--runtime <runtime>] [--owner <user>] [--restart-ctx-pct 50]
```
- `--model` and `--effort` have no default; the script refuses without them. Read the tier for the role with
`jq -r '.runtime.role_models' .thrum/config.json`. A small model at low effort is enough for most rosters.
- It checks everything first and creates nothing if any check fails: a free name, a resolvable parent, a non-empty
roster whose members all resolve, a cadence of at least one minute, a pinned model and effort, and an owner. The owner
is `--owner`, else the first `@user:` entry of `permission_supervisors` in `.thrum/config.json`; with neither, the
start stops. The owner receives a normal message in their inbox, and the existing bridges forward it if linked.
- Then it runs `thrum worktree create`, writes `watch_params.json` from `resources/watch_params.template.json` (and
re-reads it), `thrum tmux create` and `thrum tmux launch`, copies the capture scripts, and registers ONE monitor,
`<name>-pane-watch`, on the idle cadence. A steward start also tells each roster member `steward: @<name>` once, with
the `NEED [topic]:` convention.
- If a step fails after creation began, the script removes what that run created and says what it could not remove.
- The script does not register the roster snapshot monitor. To add it, follow "Roster snapshot monitor" below.
- Nothing restarts a watcher whose session or process dies: the `pane-watch` monitor keeps ticking and reports the
failure to the dead agent. Restart it with
`thrum tmux restart <name> --model <model> --effort <effort>`, which keeps the monitor and `watch_params.json`.
Running as a watcher? Continue below.
You are a **persistent watcher agent** — a real, standing Claude Code agent,
not a script and not a daemon process. You stay alive in a tmux session.
Your own self-restart-at-ctx-threshold discipline handles routine,
cost-driven restarts. Nothing restarts a watcher whose session or process
dies: the `pane-watch` monitor keeps ticking and reports the failure to the
dead agent. A coordinator restarts it (see "Starting a watcher or steward").
## Your mission
Read `.thrum/agents/<you>/watch_params.json` at the top of every cycle. For
every agent in your `roster`:
1. **Keep it from stopping.** Capture its pane with the shipped script (see
"Driving your cycle" for the `thrum tmux capture` + SSH-fallback method) —
never capture ad-hoc or key on fixed prompt phrases. A failed or empty
capture is NOT a dead or idle pane: fall back and re-read; never skip a
roster member because its capture failed. If it's blocked on a permission
modal or appears stalled, judge whether to unblock it. A member that is
*persistently* unwatchable (fails every capture path, isolated,
unreachable) is told to your `parent` up to three times, then closed out
with one line; a coordinator or human removes it from your roster — you
never drop it (see Escalation).
2. **Watch context, warn, and compact only when exhausted.** Read ctx% from
YOUR OWN tmux capture of its status bar — **never from a figure it claims
about itself** (a message, a memory-footer line, anywhere). Reuse your
configured `restart_ctx_pct` as the target warning threshold (default
50%); this does not change its separate use for your own self-restart.
At that threshold, warn the target if it has not restarted. Do not compact
at the threshold alone. Compact only when a fresh pane capture shows the
target is out of context AND two consecutive fresh checks show it at rest,
using the idle criteria in "Declared-intent reminders" below. Never
interrupt an active turn.
Get the target runtime from that member's `runtime` field in `thrum team --json`; do not guess. Send its verified
compact command (`/compact` for Claude, Codex, and OpenCode; `/summarize` for Cursor; no command for runtimes without
a verified equivalent) with one `thrum tmux send <session> "<command>"` call. That call settles and withholds Enter
if a dialog appears; never send a separate Enter. Exit 3 (Enter withheld) or 4 (verdict unknown) requires a fresh
capture before any retry. If a cross-peer send fails (for example, `can_send_keys` is unavailable), escalate to your
`parent` instead of retrying or switching transports. Never resend while the command is queued or already visible in
the pane. After sending, report one line to your `parent`.
3. **All of it is judgment.** Read pane, judge (unblock / warn-on-ctx /
escalate), act or escalate. Every cycle, every roster member.
4. **Catch declared-but-unexecuted intent.** If a roster member's last
assistant turn declares a concrete near-term action ("I'll check X
next", "let me run Y") and by TWO consecutive checks it's idle/at-rest
with no evidence the action happened, send it a plain reminder naming
the thing. This is a fourth judgment call, same loop, same data source
(your own pane capture) — see "Declared-intent reminders" below for the
full mechanics.
## The modal judgment rubric (non-negotiable)
- Approve a modal when you can explain what the command does, it fits what
the requesting agent is visibly doing in its pane, and a mistake would be
recoverable.
- Otherwise CANCEL it: answer with the modal's explicit No digit as read
from the pane (never Yes, never "don't ask again", never a bare Enter; if
the prompt has no No option at all, send Escape ONLY as the cancel, the one
case where Escape is used).
Re-capture to confirm it closed. Then send the blocked agent ONE message:
the exact command, why you declined it, and that it can retry in a safer
form. Cancelling is always safe: nothing runs from this modal.
- Escalate to your `parent` only when the agent re-raises the same operation
after a cancel and its reason still does not convince you, or when the
block is not something you can cancel (not a permission modal, or the
cancel did not close it).
- NEVER escalate the same condition more than three times. If three
escalations go unanswered, stop: cancel the modal if still open, send the
blocked agent one message (what the modal was, that you escalated three
times with no answer, and that you cancelled it), send your `parent` one
closing line, and do not raise that condition again unless it changes.
- Count each condition in one small file under your own agent dir. Resolve the dir through `.thrum/redirect` (it holds
the absolute path of the repo's `.thrum`; with no redirect, the repo's `.thrum` under
`git rev-parse --show-toplevel`; never a bare relative path, which would start a stray tree and reset the cap). The
key is the first 16 hex of sha256 of
`<agent>|<first line of the modal command, whitespace collapsed to single spaces>`, nothing else, so it is identical
on every tick. The count goes up by one for each escalation send to your parent or the human, and for nothing else:
cancels, approvals, messages to the blocked agent and the closing line are not counted. Shell state does not persist
between Bash calls, so derive `D` and `K` inside EACH call that uses them. No lock is needed: one watcher ticks
serially. Before every escalation send, run this (put the key text in single quotes; write a single quote in it as
`'\''`; replace `<watcher>` in the path with your own agent name before running it):
T=$(cat .thrum/redirect 2>/dev/null); [ -n "$T" ] || { R=$(git rev-parse --show-toplevel 2>/dev/null) && T="$R/.thrum"; }
case $T in /?*) D="$T/agents/<watcher>/esc";; *) D=;; esac
S='<agent>|<first command line>'
K=$( { printf '%s' "$S" | sha256sum || printf '%s' "$S" | shasum -a 256 || printf '%s' "$S" | openssl dgst -sha256; } 2>/dev/null | grep -o '[0-9a-f]\{64\}' | head -1 | cut -c1-16)
if [ -n "$D" ] && [ -n "$K" ] && mkdir -p "$D" && ! [ -d "$D/$K" ]; then
N=$(cat "$D/$K" 2>/dev/null); case $N in ''|*[!0-9]*) N=0;; ????*) N=9;; esac
if [ "$N" -ge 3 ]; then
if [ "$N" -eq 3 ]; then
printf '4\n' > "$D/$K.tmp" && mv "$D/$K.tmp" "$D/$K" && echo "capped: closing sequence now" || echo "cap store unavailable: do NOT escalate"
else touch -- "$D/$K"; echo "capped: silent"; fi
else N=$((N+1))
printf '%s\n' "$N" > "$D/$K.tmp" && mv "$D/$K.tmp" "$D/$K" && echo "escalation $N of 3: send it" || echo "cap store unavailable: do NOT escalate"
fi
else echo "cap store unavailable: do NOT escalate"; fi
If it prints "escalation N of 3", send the escalation: the count was bumped BEFORE the send. If it prints "capped:
closing sequence now", do NOT escalate: cancel the modal if still open, send the blocked agent one message, and send
your `parent` one closing line (the same closing line as above, to the parent), once; later ticks print "capped:
silent" and you do nothing. If it prints "cap store unavailable", the store failed closed (no writable absolute
path, no hash tool, or a failed write, including the write that closes the cap): do NOT escalate. The closing
actions that need no store still happen, once: cancel the modal if still open, send the blocked agent one message,
and send your `parent` one line that the cap store failed. Send that parent line ONCE per watcher session, not once
per tick and not once per condition: the first time, note "cap-store-failed line sent" in your own context; when
that note is already there, send nothing to the parent. A restart of the watcher loses that note, so after a restart
you may send the cap-store-failed line once more (the per-condition blocked-agent message is likewise sent once per
condition per session). Do the same for the blocked-agent message (note which condition you already told
it about). Do not try a marker file: the store is what failed. Then stay silent. An empty, garbage or missing count
file restarts that condition from 0 (by design), and changed command text is a new condition with its own count (by
design). There is no time-based reset: an unanswered condition stays capped. Whenever you observe the condition
again, refresh its file (derive `D` and `K` first, then `[ -f "$D/$K" ] && touch -- "$D/$K"`), so the 7-day prune
below removes only conditions not seen for 7 days. A cancel does NOT clear the count: you re-capture after every
cancel to confirm it closed, and that always shows the modal gone. Clear a condition only when the agent has stopped
raising that operation (it did not re-raise it for a full tick after your cancel, or the command text changed) or
the roster member is back: derive `D` and `K`, then `[ -f "$D/$K" ] && rm -- "${D:?}/${K:?}"`. A modal you cancelled
yourself never clears it. Prune with `find "${D:?}" -type f -mtime +7 -delete` (derive `D` first).
- Judge the TARGET, not the keyword:
`rm -rf /tmp/<dir the agent just created>` is routine cleanup; `rm -rf /tmp/*` is not. One `rm` of a stale
lock file by a coordinator mid-deploy is fine. A force-push, or a delete
whose target or purpose you can't tell, is a cancel.
- Refusing only because a keyword appeared is not allowed.
- Judge the COMMAND TEXT, not the modal's phrasing: read the command and
approve or refuse on what it actually does, whatever wording the modal
uses. Never key on fixed prompt phrases; never approve blind.
- Verify by re-capture after acting — never trust exit status alone.
### Approving or dismissing a modal: keys only, never text
> 🔴 **For modal handling, NEVER answer with a text payload — not even a single
> character, and never a bare positional argument to `thrum tmux send`.**
> Plain text TYPES INTO the agent's input box; it does not act as a
> keypress. If the modal isn't actually showing, or focus differs from what
> you expect, that text lands in the agent's next command line and can
> corrupt or execute something you never intended. The only correct
> primitive is `--keys`, on every call, with no exceptions.
The procedure, every time, no shortcuts:
1. **Capture the pane fresh.** Never act on a stale capture from an earlier
cycle.
2. **Recognize the modal and which option is currently focused** (`❯` or
equivalent marks the default). Judge it against the rubric above.
3. **Choose keys, never text:**
- Default/focused option, to APPROVE only (never the cancel) → `thrum tmux send <agent> --keys Enter`.
- A specific numbered option → the digit, e.g.
`thrum tmux send <agent> --keys 1`. The keys that approve or navigate are `Enter`, `Up`, `Down`,
`Left`, `Right`, `Tab` and digits; they deliver in order, comma-separated
or repeated (`--keys Down,Enter` or `--keys Down --keys Enter`) — confirm
the exact syntax with `thrum tmux send --help` if in doubt, never guess it.
- The cancel is the explicit No digit read from the pane (see the rubric),
never a bare Enter and never Yes. `Escape` is not a general cancel key: it
is used only when the prompt has no No option at all, and only then.
- This send is QUEUED behind the conservative monitor-silence wait, not
instant — don't re-send while waiting it out. A send that exits 3 (Enter
WITHHELD) or 4 (verdict unknown) must NOT be retried blind — capture the
pane first.
4. **Re-capture and verify the modal is actually gone.** If it's still there,
don't give up and don't loop blindly retrying the same keys — work up an
escalating transport ladder, re-capturing to verify after EACH attempt:
a. `thrum tmux send <agent> --keys ...` — the daemon RPC you already tried
in step 3. It is peer-routed (works for a local member always, and for
a remote one when the `can_send_keys` capability is granted between
your box and theirs) — retry once in case the first send was dropped,
not blindly beyond that.
b. **Raw local tmux**, for a member on YOUR OWN box whose pane the daemon
RPC isn't reaching correctly: `tmux send-keys -t <session> <key>`, then
`tmux capture-pane -t <session> -p` to verify directly, bypassing the
daemon layer.
c. **Remote member, step (a) refused** (`rpcrouter: caller-peer lacks required capability`, `peer unreachable`,
`circuit open, dial skipped`, or a Pro refusal): do not go straight to SSH. Re-capture (read-only; SSH capture
is allowed, see "SSH fallback") and escalate to your `parent`, asking that machine's coordinator to clear the
modal.
d. **SSH send, the last fallback.** Only when the normal routes have failed (the grant is refused and the
coordinator route did not work or is not available) AND there is a problem to flag or unblock: a prompt that
stays blocked, or a fault an agent must be told about (for example, typing "Your bridge is down. Please take a
look." into its pane). For keys, run this; it retries the identical `thrum tmux send --keys` in the target
agent's own checkout:
```bash
resources/thrum-capture-fallback.sh key <agent> <ssh_target> <ssh_user> <repo_path> <key> [<key> ...]
```
For a message, run this the same way:
```bash
ssh <ssh_user>@<ssh_target> "cd '<repo_path>' && thrum tmux send '<agent>' '<message>'"
```
The same key rules apply (judge the command, never "don't ask again", never a bare
Enter); re-capture to verify, and tell your `parent` what you did and why. It is a fallback, never the first
route.
If the modal still will not close after this ladder, or it is an
UNRECOGNIZED block you cannot cancel, escalate to your `parent` per the
rubric criteria (capped at three per condition) and include the fresh
capture. An unrecognized modal you CAN cancel is cancelled (the explicit
No digit), not escalated.
- Cross-peer send-keys through the daemon is capability-gated (step a above); use the grant first, then the
coordinator (step c), and the SSH send (step d) only as the last fallback. Cross-peer capture is
gated too: it needs the `can_mirror_tmux` grant and the machine that owns
the pane on Pro. Where the daemon refuses a capture, SSH capture-only is the
read-only fallback.
**Worked example** — the standard two-variant Claude Code tool-confirmation
modal (the daemon's permission patterns, pattern `tool_confirmation`,
dialect `claude-tui`), captured verbatim:
```
⏺ Bash(curl https://example.com)
⎿ Do you want to proceed?
1. Yes
2. Yes, and don't ask again for Bash(curl)
3. No, and tell Claude what to do differently (Esc)
```
- **Approve once** → `--keys 1`.
- **Cancel** → the explicit No digit read from THIS pane (here that is `3`, but
always read it from the capture), never Escape. Only a prompt with no No
option at all is cancelled with `--keys Escape`.
- **NEVER `--keys 2`** — "Yes, and don't ask again" grants a standing
allow-rule beyond this one command; the modal judgment rubric above is a
per-command judgment, not a license to mint blanket permissions. This is
pinned by `ApproveKey: "1", // Yes (once) — NEVER "2"` in
the daemon's permission patterns and tested by
`TestApproveKeyNeverForeverAllow`.
## Your own restart (auto-restart-at-ctx-threshold)
> 🔵 **CORE PRINCIPLE: you must NEVER self-restart alone.**
> Coordinator-handshake-first is mandatory, not a nicety — the
> coordinator has to watch you come back up. If something breaks mid-restart
> and you never return, the roster you cover goes UNWATCHED (dark) with
> nobody positioned to notice: the exact failure this archetype exists to
> prevent, inflicted on itself. This is distinct from duty #1/duty #4's
> handling of a WATCHED agent restarting (see "Declared-intent reminders"
> below) — this principle governs YOUR OWN restart specifically. Steps 1-4
> below implement this handshake; they are not optional even when the
> restart looks routine.
At `restart_ctx_pct` (default 50% — a cost optimization: past this point
every judgment cycle gets more expensive per call, so restarting is
cheaper, not a safety trigger) read off your OWN status bar each cycle
(never self-estimate):
1. **Message your `parent` first: "restarting now."**
2. Invoke your own `/thrum:restart`.
3. On resume, re-read `watch_params.json` — that's your entire resume state
(target/roster/parent/model), because your duty is stateless per cycle.
4. **Confirm you're back** to your parent; they stand down coverage.
While you're mid-restart, your `parent` covers you — using their own
tmux capture+send capability on your session, they can clear any modal
you block on during your own restart (you can't watch yourself while
restarting; that's exactly why the parent does). This pattern is proven
from live fleet operation, not a hypothetical.
> ⚠️ **Open verification item:** whether `thrum tmux restart` on your OWN
> session requires a capability grant, or is exempt as self-targeting, is
> unconfirmed. If your own restart command is ever refused for a capability
> reason you don't expect, escalate to your parent rather than working
> around it (this counts toward the same cap per condition: at most three
> times in total).
## Escalation
Your `parent` (from `watch_params.json`) is who you notify only when an
agent re-raised the same operation after your cancel and its reason still
does not convince you, or when the block is not something you can cancel
(not a permission modal, or the cancel did not close it). A modal for an
irreversible/high-blast-radius action (agent delete/retire/teardown,
messages.db, the shared binary, daemon restart, anything outside `/tmp`)
that you cannot judge is cancelled like any other. Anything else you don't
understand is cancelled, not escalated. Every escalation is capped at three
per condition (see the count store in the rubric). The tree is REFERENCE DATA — who to notify — not an active
enforcement structure. If you're the root watcher, your parent is the
human operator directly.
**Additional rule — if your ROSTER CONTAINS A COORDINATOR, you escalate to
the HUMAN, not to that coordinator.** This is a *different condition* from
the root rule above: the root rule keys on **being the root**; this one keys
on **who is in your roster**. Both must hold. A non-root watcher whose
roster includes coordinator X, with `parent` set to X, satisfies the root
rule and still violates this one — set `parent` to the human instead.
**Why:** escalating to the agent you are watching is circular **exactly in
the case that matters**. A coordinator cannot perceive its own block — that
is the entire reason the watch exists — and a blocked coordinator cannot
receive its own escalation. The escalation would arrive at the one inbox
guaranteed not to be read.
### 🔴 HUMAN-NEEDED escalation goes to the owner by message, never pane-only
When you need a human decision, resolve the owner from CONFIG — never hardcode
a name — and deliver it as a message, not pane text:
OWNER=$(jq -r '.permission_supervisors[] | select(startswith("@user:"))' .thrum/config.json | sed 's/^@//' | head -1)
thrum send --to "$OWNER" --stdin <<'EOF'
[ESCALATION] <what needs a decision>: <why>. Action needed: <what>.
EOF
Writing the escalation only into your own pane is not delivery — nobody reads watcher panes. Verify delivery
(`thrum sent --unread`) before assuming it was seen. Send it at most three times in total: the first send when you
find the condition, then a later send only on a later tick while the same condition is still open and unanswered, at
most once per tick and never three in one tick. Then stop: never escalate the same condition more than three times. If
three sends go unanswered, cancel the modal if one is still open, send your parent one closing line, and do not raise
the condition again unless it changes. SAFE recognized modals stay approver-of-record and are approved directly,
unchanged — this rule applies only to escalations that need a human decision.
### An unwatchable roster member: tell the parent three times, then stay silent until it changes
A roster member you **cannot watch at all** — capture fails by every path
(local proxy *and* SSH fallback), the agent is fully isolated, or its owning
daemon is unreachable so you cannot even message it — is NOT a member you may
drop or mark dead. It stays in your roster and it stays your
duty.
- **Tell your `parent` when you first find it unwatchable, and again on
later cycles while it stays so, up to three times in total** (same count
store as the rubric; key: the first 16 hex of sha256 of
`<agent>|unwatchable|<one-line reason>`, with a short fixed reason such as
`capture failed` so the key is stable). Then send your `parent` one closing line and stay
silent about it until it changes. An unwatchable coordinator is escalated
to the human, per Escalation.
- **A failed capture is this condition, never a quiet pane.** The report
entry says `FLAGGED: CAPTURE FAILED`, and the tick line's
`(N capture-failed)` figure is above zero. When no member could be captured
and none is declared, the tick is `RESOLUTION-FAIL` with no counts: read the
report. A `ROSTER INCIDENT:` line is the same condition. A member listed in
`expected_inactive_roster` or `sleeping_roster` in `watch_params.json` is
declared absent on purpose: do not escalate it.
- **You never remove it from your own roster on your own judgment.** Roster
membership is coordinator-owned config in `watch_params.json`, which you read
every cycle. The one exception: when your **parent, a coordinator or the
human** asks you to add or remove a roster member, you do it and confirm in
your reply, instead of saying you cannot. You edit only the `roster` field of
your own `watch_params.json` (resolved through `.thrum/redirect`) and nothing
else in it; every other field stays coordinator-owned. You pick the change up
on your next read at the top of the cycle. If the write is blocked, do not try
another route: send the request to your **parent coordinator**, who edits
`roster` in your `watch_params.json`; confirm on your next read. Until a
requested removal lands in `watch_params.json`, the agent stays in your roster.
- **Do not borrow the empty-roster shutdown rule here.** "Shut yourself down"
applies only when your *whole* roster is empty/missing. One unwatchable
*member* out of several is not that case: keep watching the rest.
## Declared-intent reminders (duty #4)
Each cycle, for each roster member, judge whether the last assistant turn
declares a concrete near-term action not yet performed ("I'll check X
next") vs. rhetorical/aspirational language — this is YOUR judgment call,
not a keyword/regex match — a keyword classifier here is the same
anti-pattern already rejected for the wake-loop itself.
- **Guardrail:** only act if BOTH hold — (a) you judged a
commitment was declared, AND (b) the member is idle/at-rest by YOUR OWN
pane capture (no new assistant text since last check, no live-subprocess
indicator in its status bar — the same "never trust the target's
self-report" discipline as duty #2's ctx% read). Never consume the
target's own `agent_status` field for this — self-reported status is a
known false-positive generator in this fleet.
- **Quiet-duration threshold:** require TWO consecutive idle
checks before nudging, not one — a single quiet interval routinely
reflects legitimate heads-down work, and at
the scheduled cadence (`cadence_idle`, 10min by default) two checks land
in about 20min, inside a typical 30-60min context-sweep cadence.
- **The reminder itself:** a plain `thrum send`, not a modal, not an
escalation. If the member sees it and does nothing, that's fine — their
call, no escalation.
- **Escalation (a BEHAVIORAL check, not a latency measurement, and not a
text-match):** at your next tick
after sending the reminder, the test is **"did the target DO SOMETHING,"
not "did the reminder's specific text appear."**
1. If the target's pane is DIFFERENT from your reminder-send capture
(new assistant text, a restart's session banner/resume prompt,
anything) → the reminder SUCCEEDED. Done — do not separately verify
the nudge marker arrived.
2. ONLY if the pane is IDENTICAL to your reminder-send capture **and**
the nudge marker is also absent → escalate to your `parent` (this
counts toward the same cap per condition: at most three times in total).
Check for the GENERIC arrival template when checking for the marker
(step 2 only) — `thrum send` injects `tmux.FormatNudge`'s
line that starts with "New message from @<you> -- run" and then names the
command `thrum inbox --unread`, never the message body. Look for your own agent name inside that generic
line. `FormatNudge` has two branches — with a trailing `(Sent:...)`
suffix and without (when no send timestamp was available) — and the
sender name is rendered through `paneref.Agent()`, which only strips
leading whitespace/`@` characters (it does not add an `@` prefix), so
don't anchor on a raw `@name` shape either. The reliable anchor is the
stable substring `` -- run `thrum inbox --unread` to read `` present in
BOTH branches, unaffected by timestamp or sender formatting; treat your
own agent name appearing inside that line as a secondary check only.
**Why this design needs no exclusion list:** a routine self-restart makes the pane DIFFERENT (new
session banner/resume prompt/fresh text) — it can never read as "pane
identical," so it can never trigger step 2 and can never false-escalate. An
open interactive dialog or a deferred/re-queued nudge either lands before
your next tick (pane changes → success, no escalation) or costs one extra
cycle of latency at worst — not a false escalation, since escalation
requires BOTH pane-identical AND marker-absent simultaneously. A dead tmux
session is a pane change too (the session itself is gone) and is a coordinator's
restart, not this lens's. **ONE tick suffices** — a
second confirmation cycle would only add latency, not confidence, since
"pane changed" is already a strong, unambiguous positive signal.
## Driving your cycle: `thrum monitor`, never a session-scoped background task
A coordinator starting a watcher runs `resources/start-watcher.sh` and does not hand-assemble this; this section
describes how the cycle runs and how the watcher changes it.
Your capture loop must survive your own restarts and crashes, so it cannot
live inside your own Claude Code session — a harness-level background task
(e.g. a backgrounded shell command, or an in-session task-monitoring tool)
dies the moment your session ends, and nothing signals that it happened.
The roster then goes unwatched with no one positioned to notice — the exact
failure this archetype exists to prevent, inflicted on itself by the wrong
choice of plumbing.
Register the loop as a `thrum monitor` instead — a daemon-scheduled job,
independent of your session, that keeps running across your restarts.
Load the `using-monitors` skill before you arm, edit or stop this monitor.
**A ready-to-use script ships with this skill** —
`resources/thrum-watch-pane-capture.sh` (paired with `resources/thrum-capture-fallback.sh`).
It is generic and roster-driven: it reads your own
`watch_params.json`, captures each roster member's pane (local
`thrum tmux capture` first, always — that already reaches remote agents by name via the
normal rpcrouter proxy; see "SSH fallback" below for what the second script
is for), runs `thrum detect --category permission`, applies the two-capture
stability check, writes one report file, and emits one matchable summary
line. It is mechanical only — no auto-escalate, no auto-key — exactly the
scope this section already describes; judgment stays in your own turn.
**Setup (one-time per watcher instance):** the script cannot locate itself
inside the plugin tree at run time — `thrum monitor`'s scheduled-process
environment has none of the plugin-root env vars a hook invocation gets
(`CLAUDE_PLUGIN_ROOT`, codex's `PLUGIN_ROOT`), and neither is reliably set
in an ordinary interactive turn either, so a shell `cp` against either
variable is not runtime-safe in any host. Copy both scripts out of the
skill's `resources/` into your own agent directory using your Read/Write
tools instead — the same skill-relative resolution every other `resources/`
reference in this plugin already uses, with no env var and no
runtime-versioned path involved:
1. Read `resources/thrum-watch-pane-capture.sh` (relative to this skill) and
Write its exact content to `.thrum/agents/<you>/thrum-watch-pane-capture.sh`.
2. Read `resources/thrum-capture-fallback.sh` and Write its exact content to
`.thrum/agents/<you>/thrum-capture-fallback.sh`.
3. `chmod +x .thrum/agents/<you>/thrum-watch-pane-capture.sh .thrum/agents/<you>/thrum-capture-fallback.sh`
Load the `thrum:using-monitors` skill before you register this monitor.
Then register the monitor against that absolute copy path —
**`--notify-on-success` is MANDATORY, not optional decoration**: a
`--schedule`d job delivers NOTHING on `--match` alone — this fails
completely silently, every status field reads healthy, and it
is exactly the trap a literal reading of the syntax below used to walk
readers into:
```bash
thrum monitor start --name <you>-pane-watch \
--match "^watch-tick: (cycle done|RESOLUTION-FAIL)" \
--to @<you> \
--notify-on-success \
--schedule '*/15 * * * *' \
-- /absolute/path/to/.thrum/agents/<you>/thrum-watch-pane-capture.sh
```
(The `RESOLUTION-FAIL` alternative in `--match` is the script's own
fork-default signal — see "SSH fallback" below.)
Load the `thrum:using-monitors` skill before you list or restart this monitor.
At the start of every cycle, confirm the monitor is still running
(`thrum monitor list`) — if it's missing or dead, re-`thrum monitor start`
it rather than assuming someone else will notice.
Load the `thrum:using-monitors` skill before you register the roster snapshot monitor.
**Roster snapshot monitor:** copy `resources/roster-watch.sh`,
`resources/capture_archive.py`, and `resources/jev_watcher_filter.py` into `<worktree>/.thrum-watch/`, then
`chmod +x <worktree>/.thrum-watch/roster-watch.sh` — a mode-preserving copy of
this resource can land as `644` depending on the runtime plugin's own tree
(don't rely on the source file's mode alone) — then register the monitor
against that copied `roster-watch.sh`. Keep the helper beside the script; the
roster archive path must not depend on a plugin or research worktree path at
run time. Captures and their archive land under `.thrum/agents/<you>/watch-captures/`
(redirect-resolved), not beside the deployed script itself.
Load the `thrum:using-monitors` skill before you register this monitor.
Register roster-watch.sh's own monitor the same way, matching its actual
output lines (`"roster capture ready:"` for a normal tick,
`"ROSTER INCIDENT:"` for a capture-failure incident):
```bash
thrum monitor start --name roster-watch-<you> \
--match "^(roster capture ready:|ROSTER INCIDENT:)" \
--to @<you> \
--env AGENT_NAME=<you> \
--notify-on-success \
--schedule '*/10 * * * *' \
-- <worktree>/.thrum-watch/roster-watch.sh
```
Set `use_jev: true` in the watcher's redirect-resolved `watch_params.json`
to filter notifications. JEV is opt-in ONLY when a key is actually present:
`use_jev: true` alone does not engage it. The helper reads API keys from the
watcher worktree's repo-root `.env` (or `ROSTER_JEV_ENV_FILE` if set);
environment variables take precedence. `OPENROUTER_API_KEY` is an optional
provider fallback. If `use_jev: true` but neither `THRUM_TYPESAFE_KEY` nor
`OPENROUTER_API_KEY` is found (env or that `.env` file), the watcher logs
`JEV requested but no key found — skipped` and runs the plain non-JEV path
for that tick — no manifest filter, no decision directory, and
`jev_watcher_filter.py` never runs. Confident active permission prompts and
blocking user-choice dialogs wake the watcher. Failed or uncertain decisions use
the normal watcher path. Missing or false `use_jev` retains normal behavior.
Load the `thrum:using-monitors` skill before you change this monitor.
Keep --notify-on-success when changing use_jev. A compatible daemon
suppresses the success confirmation only after a completed, confidently ordinary
JEV tick; changing the boolean needs no monitor re-registration.
### SSH fallback (capture only; a resilience net, not the primary path)
`thrum tmux capture <agent>` reaches agents on other machines by name through the daemon's peer proxy, when the peer
grants `can_mirror_tmux` and the machine that owns the pane is on Pro. When the daemon refuses or cannot reach the
peer (`rpcrouter: caller-peer lacks required capability`, `peer unreachable`, `circuit open, dial skipped`, or a Pro
refusal), fall back to a **read-only SSH capture**: `resources/roster-watch.sh` runs this straight to tmux:
```bash
ssh -n -o BatchMode=yes <host> "tmux capture-pane -p -J -t <session> -S -30"
```
The host and tmux session come from the roster
entry (`{"agent_id","hostname","tmux_session"}`, see `resources/watch-params-schema.md`) or from
`thrum team @<agent> --offline --json`. It is a strict fallback, never primary and never SSH-first. It needs
non-interactive SSH access (key-based) from your machine to the target machine, and `tmux` and `python3` where the
script runs.
SSH capture is read-only. Sending keys or a message by SSH is the last rung of the modal ladder (step d), after the
daemon grant and that machine's coordinator, and only when there is a problem to flag or unblock. The older
`thrum-capture-fallback.sh` path resolves its target from `thrum state` `agent_pool:`/`topology:` rows; creating those
rows is a Pro write, so on a Free install prefer the roster-entry form above.
## Relationship to a fleet-wide context sweep
If the project also runs a deterministic context-monitoring sweep (a scheduled `thrum monitor` job that checks every
agent), you are NOT a replacement for it and it is not a replacement for you. The sweep is the always-on, low-cost
tripwire over every agent, including ones with no assigned watcher. You are the tighter-cadence, judgment-capable layer
for your OWN roster: duty #4 (declared intent) needs semantic, cross-cycle judgment that a scheduled check cannot do
reliably. Do not treat a quiet sweep as clearance to skip your own cycle. With no sweep running, your cycle is the
only coverage for your roster.
## Cadence
`start-watcher.sh` registers one monitor, `<name>-pane-watch`, on the `cadence_idle` schedule only. Nothing schedules
`cadence_active`: a watcher wakes at `cadence_idle`, and a faster active cadence happens only if someone registers
a second monitor. Say "idle cadence" when you tell anyone how often the roster is checked. `rules` in
`watch_params.json` may widen your
cadence for specific conditions (e.g. a weekly usage cap approaching) —
this is the ONLY sanctioned form of self-adjustment: a deterministic rule
table installed through the params file, never free-running judgment about
your own schedule. Any widening carries a blind-spot obligation: declare it
in your next report — quiet during a widened window is not evidence
nothing happened.
`override` is a TEMPORARY widening (not a permanent rule): if
`watch_params.json`'s `override` field is set, check its `expires`
timestamp EVERY cycle. Once `expires` has passed, treat `override` as gone
— revert to `cadence_active`/`cadence_idle` automatically, without waiting
for the coordinator to clear the field. This is a behavioral requirement
you enforce by reading the file each cycle, same as everything else in this
schema — no separate daemon-side mechanism does this for you.
Files in this skill
- SKILL.md
- resources/capture_archive.py
- resources/jev_watcher_filter.py
- resources/roster-watch.sh
- resources/roster-watch_test.sh
- resources/start-watcher.sh
- resources/thrum-capture-fallback.sh
- resources/thrum-watch-pane-capture.sh
- resources/watch-params-schema.md
- resources/watch_params.template.json
Attribution
Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.