Skip to content
Back to skills

Persistent Watcher Archetype

BSecurity

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`).

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 7, 2026
ai-agentspythonrustgoshellbashgitapi

Works with

  • claude code
  • cursor
  • cli
  • api

Security analysis

B81/100
  • mediumUses curl or wget to download content
  • highPerforms destructive filesystem operations

Pro scans all 11 files and shows the line behind each finding

Scanned November 7, 2026

npx -y skills add thrum-team/thrum-pro --skill persistent-watcher-archetype --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Persistent Watcher Archetype?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Persistent Watcher Archetype
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/thrum-team-persistent-watcher-archetype-thrum-pro/badge)](https://www.skillsdirectory.com/skills/thrum-team-persistent-watcher-archetype-thrum-pro)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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."
# source: claude-plugin/skills/persistent-watcher-archetype/SKILL.md
---

## 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: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.md38.2 KB
  • agents/openai.yaml1.1 KB
  • resources/capture_archive.py22.5 KB
  • resources/jev_watcher_filter.py38.9 KB
  • resources/roster-watch.sh53.8 KB
  • resources/roster-watch_test.sh18.7 KB
  • resources/start-watcher.sh12.3 KB
  • resources/thrum-capture-fallback.sh6.5 KB
  • resources/thrum-watch-pane-capture.sh21.3 KB
  • resources/watch-params-schema.md6.4 KB
  • resources/watch_params.template.json208 B

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.