SLOPSHOPPER

rrstop

Watchdog test fixture, not a mod to install. live probe: TaskStop results and the per-plugin spawn cap

newagents
A shopper browsing a rack in a slop shop
README

watchdog

CI

A second model reviews each step that Claude Code takes and sends it short notes while it works: nit, concern or blocker.

A watchdog flags a planted bug and nudges Claude, which fixes it; the band card marks the note as maybe outdated and opens to the whole note, /watchdog status shows the review cost, and a later review retracts the note and raises a held concern: Claude reported a result it never ran

Requirements

Claude Code 2.1.290 or later. Watchdog is a mod: a plugin whose code Claude Code runs inside your session. The npm stable channel (2.1.285 on 2026-10-06) has no mods. Below 2.1.290 the plugin shows unsupported. Desktop support starts when Claude.app bundles Claude Code 2.1.290 or later.

Check claude --version first. If it is below 2.1.290, move to the npm latest channel: npm install -g @anthropic-ai/claude-code@latest.

Quick start

/plugin marketplace add matteoantoci/claude-plugins
/plugin install watchdog@matteoantoci-plugins

Then run /watchdog on (reviews are off until you do) and ask Claude for a small change. The note shows as a watchdog: [concern] … line in the transcript and as a card, one line with its first sentence above the prompt box. Click the card's ▸, or press ctrl+x tab and then its letter (a, b, c), to read the whole note with its watchdog, age and state; Esc gives the focus back to the prompt. Run /watchdog status to see each watchdog's reviews, notes, tokens and cost. A card names its watchdog when you run two or more.

At its right end a card shows only what needs a look: the subagent type for a note on a subagent; the state while the note has not reached Claude yet, nudge pending (the plugin starts a turn so that Claude reads it), held or aside (Claude reads it with your next prompt); nothing once it is steered (Claude reads it after its next tool result) or nudged. When Claude edited files after the review read its update, the card says outdated? N edits (the open card says may be outdated: N edits since), and Claude reads the same mark with the note. The next review of the same watchdog sees the edits and the note; when the note no longer holds, it retracts it: the card goes, and a note that waits never reaches Claude. A blocker that may be outdated and came after Claude's reply waits as held for that review before it nudges, so Claude does not go after a bug it already fixed.

What runs on your machine

  • The mod runs inside Claude Code with your permissions. Its code is in plugins/watchdog/hooks/.
  • It reads the WATCHDOG.json and WATCHDOG.md files, the session's memory files (such as CLAUDE.md), each update of the agent you work with and, when CLAUDE_WATCHDOG is set in a claude -p run, your project and local settings.
  • It sends each update to the review model, as an agent that Claude Code runs on your account, and puts the notes into your session. /watchdog on also sends one 1-token request for each model, to check that it exists. Apart from these model requests through Claude Code, the mod makes no network calls: its code never calls $.http.fetch or fetch.
  • Its only file write is the dump, under <config>/watchdog/dumps/ (<config> is $CLAUDE_CONFIG_DIR or ~/.claude). It keeps its notes and review state in Claude Code's session state and plugin store. In the terminal, /watchdog dump also copies the dump text to the clipboard.
  • By default a reviewer gets Read, Grep and Glob. A project WATCHDOG.json can grant no more; only <config>/WATCHDOG.json can grant other tools and mcp__* tools. Bash, Edit, Write, NotebookEdit, Agent, SendMessage, AskUserQuestion and ToolSearch are always refused. A reviewer never asks you for a permission.
  • The mod allows its own review spawn (the Agent call of a watchdog:* type) when Claude Code would ask, so no dialog or Auto-mode classifier sees it. A permission rule that denies Agent still wins.
  • A project WATCHDOG.json or WATCHDOG.md sets the number of reviewers, their model, effort and instructions. In a repo you did not write, read these files before /watchdog on. Once on, /watchdog status lists each watchdog with its model, effort and file.

Cost and off switch

  • Each review is one more agent, on opus with medium effort by default (in the demo: 3 reviews, 37.2k tokens, $0.07). The built-in "You should know" mod, when on, runs its own side agent too; turn it off in /plugin to pay for one only.
  • /watchdog off stops reviews for this session. /plugin uninstall watchdog@matteoantoci-plugins removes the plugin.
  • Settings, in /plugin (Installed, Watchdog, Configure options) or /config: onByDefault (default false) turns reviews on in each new interactive session. immuneTurns (0 to 5, default 3) is the number of turns after a nudge before the next nudge for a concern; a nudge is a turn that the plugin starts so that Claude reads a note that came after its reply.
  • Each nudge is one more turn of Claude. After each of your prompts the plugin sends at most 1 nudge for concerns and 2 for blockers (a concern that comes with a blocker rides along); a later note waits for your next prompt. /watchdog status shows both counts, for example nudge 1/1 · blocker 0/2.

Commands

  • /watchdog or /watchdog status: each watchdog's state, reviews, notes, tokens and cost, and the session totals. For a state such as halted, see docs/failures.md.
  • /watchdog on and /watchdog off: turn reviews on or off for this session.
  • /watchdog dump and /watchdog dump raw: write the review log to a file (raw adds the review prompts).

Configure

A WATCHDOG.json in your project or in ~/.claude sets the watchdogs. The load order, every key and the tool grants are in docs/configuration.md. This file adds a second reviewer to the default one:

{ "watchdogs": [{ "name": "default" }, { "name": "security", "model": "sonnet", "effort": "high" }] }

Limitations

  • Notes are advice: the agent may reject one. A review runs in the background, so a note can come after the step; the outdated mark above counts every edit since the review, whatever file it touched.
  • Reviews run only on Anthropic models. claude -p needs CLAUDE_WATCHDOG=on and has no nudge and no cards: see docs/headless.md.
  • The cost comes from the plugin's own price table (plugins/watchdog/hooks/prices.ts); a model not in it shows $?.

Development

npm install sets up the tools and the pre-commit hook. npm run check runs the 6 checks of pre-commit and CI: rules, fmt:check, lint, typecheck, validate and test. See docs/plugin-dev.md. Before a release and before a bump of the pinned Claude Code version, run the live probe by hand, on a real model and login: scripts/live-probe/README.md.

License

Apache-2.0

Source 1 files
hooks/stop.mjs 258 lines
1// §16.5 review stop and spawn caps (spec §7.8, §12.2; research/stop-caps-probe.md "What I did not check").
2// installMod fills __LOG__, __MODE__ (`headless` or `tui`) and __HOLD__, the proxy's agentMark: each request of a
3// `holder` agent gets a 529 from the proxy, so the agent waits on the model until it is stopped, at no token cost.
4// Each phase runs in the main turn.complete hook (a live frame) of the prompt whose text carries its marker.
5// Result: one JSON object at __LOG__/rr-stop.json.
6//
7// headless, default caps (no CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS):
8//   RRSTOP1  TaskStop on an unknown id; holders until the per-plugin cap refuses one; TaskStop on the first holder
9//            while it waits on the model, then once more at once.
10//   RRSTOP2  TaskStop on that stopped holder; a spawn into the slot it freed (`ender`, a real model call); TaskStop
11//            on the other holders.
12//   RRSTOP3  TaskStop on `ender`, which ended by itself.
13// tui, CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS=1:
14//   RRHOLD   one holder, then a spawn that the per-plugin cap refuses.
15//   RRPEER   the rr-peer plugin (inner, so it acts first) meets the session cap; then the holder stops.
16//   RRCAP    a `capper` agent with maxTurns 2 in the slot the stop freed.
17const FILE = '__LOG__/rr-stop.json';
18const PEER_FILE = '__LOG__/rr-peer.json';
19const MODE = '__MODE__';
20const HOLD = '__HOLD__';
21const PLUGIN = 'rrstop';
22// Shaped like an agent id (`a` + 16 hex), but no task has it.
23const UNKNOWN_ID = 'a0123456789abcdef';
24const MAX_HOLDERS = 25;
25const CAPPER_TASK =
26  'In every reply, write one line that starts with "ROUND <n>:" and call the Glob tool with pattern "*.txt". ' +
27  'Do 5 rounds.';
28
29const state = { mode: MODE, phase: 'idle', pending: null };
30let lastText = '';
31let writing = Promise.resolve();
32
33const errOf = (err) => ({ name: err?.name ?? 'Error', message: String(err?.message ?? err).slice(0, 800) });
34
35const write = ($) => {
36  writing = writing.then(() => $.fs.write(FILE, JSON.stringify(state))).catch(() => undefined);
37  return writing;
38};
39
40// One awaited step; `pending` names it until it settles, so a call that never settles shows in the result.
41const step = async ($, key, fn) => {
42  state.pending = key;
43  await write($);
44  state[key] = await fn();
45  state.pending = null;
46  await write($);
47  return state[key];
48};
49
50const spawn = async ($, tag, type, prompt) => {
51  const at = Date.now();
52  try {
53    const resolved = await $.agent.spawn({ subagentType: `${PLUGIN}:${type}`, description: `probe ${tag}`, prompt });
54    return { tag, at, agentId: resolved?.agentId ?? null, resolved };
55  } catch (err) {
56    return { tag, at, agentId: null, rejected: errOf(err) };
57  }
58};
59
60// §7.8: the stop route; the task id is the agentId.
61const taskStop = async ($, agentId) => {
62  const at = Date.now();
63  try {
64    const resolved = await $.tool.call({ tool: 'TaskStop', task_id: agentId });
65    return { agentId, at, ms: Date.now() - at, resolved };
66  } catch (err) {
67    return { agentId, at, ms: Date.now() - at, rejected: errOf(err) };
68  }
69};
70
71const statusOf = async ($, agentId) => {
72  try {
73    const list = await $.agent.list();
74    const row = (Array.isArray(list) ? list : []).find((agent) => agent.id === agentId);
75    return row?.status ?? 'absent';
76  } catch (err) {
77    return `list-error: ${String(err?.message ?? err).slice(0, 200)}`;
78  }
79};
80
81// §12.2: holders until the per-plugin cap refuses one (`over`); `running` is how many held then.
82const holdUntilCap = async ($) => {
83  state.holders = [];
84  for (let i = 1; i <= MAX_HOLDERS && !state.over; i += 1) {
85    const spawned = await spawn($, `holder ${i}`, 'holder', `Reply with exactly HELD${i}.`);
86    if (spawned.agentId) {
87      state.holders.push(spawned.agentId);
88    } else {
89      state.over = { ...spawned, running: state.holders.length };
90    }
91  }
92  await write($);
93};
94
95// RRSTOP1. The 1.5 s sleep lets the first holder's request reach the proxy and get its 529 (retry-after 20 s).
96const stopFirst = async ($) => {
97  await step($, 'unknown', () => taskStop($, UNKNOWN_ID));
98  await holdUntilCap($);
99  const first = state.holders[0];
100  if (first) {
101    await $.clock.sleep(1500);
102    state.stopAt = Date.now();
103    await step($, 'stopLive', () => taskStop($, first));
104    await step($, 'stopTwice', () => taskStop($, first));
105  }
106  state.phase = 'stopped';
107};
108
109// RRSTOP2: a later dispatch, after the stopped holder's turn.complete, so its slot had time to free.
110const stopEnded = async ($) => {
111  const first = state.holders?.[0];
112  if (first) {
113    state.killedStatus = await statusOf($, first);
114    await step($, 'stopEnded', () => taskStop($, first));
115    await step($, 'slot', () => spawn($, 'ender', 'ender', 'Reply with exactly ENDED.'));
116  }
117  state.cleanup = [];
118  for (const agentId of (state.holders ?? []).slice(1)) {
119    const stopped = await taskStop($, agentId);
120    state.cleanup.push({ agentId, isStopped: String(stopped.resolved?.text ?? '').includes('Successfully stopped') });
121  }
122  state.ender = state.slot?.agentId ? state.slot : await spawn($, 'ender', 'ender', 'Reply with exactly ENDED.');
123  state.phase = state.ender.agentId ? 'ender' : 'done';
124};
125
126// RRSTOP3: `ender` answered and ended on its own before this prompt.
127const stopComplete = async ($) => {
128  state.enderStatus = await statusOf($, state.ender.agentId);
129  await step($, 'stopComplete', () => taskStop($, state.ender.agentId));
130  state.phase = 'done';
131};
132
133const tuiHold = async ($) => {
134  await holdUntilCap($);
135  state.phase = 'held';
136};
137
138// RRPEER: rr-peer's hook is inner, so its spawn settled already; wait up to 3 s for its file all the same.
139const tuiPeer = async ($) => {
140  for (let i = 0; i < 15 && !(await $.fs.exists(PEER_FILE).catch(() => false)); i += 1) {
141    await $.clock.sleep(200);
142  }
143  const first = state.holders?.[0];
144  if (first) {
145    state.stopAt = Date.now();
146    await step($, 'stopLive', () => taskStop($, first));
147  }
148  state.phase = 'stopped';
149};
150
151const tuiCap = async ($) => {
152  await step($, 'capper', () => spawn($, 'capper', 'capper', CAPPER_TASK));
153  state.phase = 'done';
154};
155
156// [marker, phase it starts from, action]
157const PHASES =
158  MODE === 'tui'
159    ? [
160        ['RRHOLD', 'idle', 'hold'],
161        ['RRPEER', 'held', 'peer'],
162        ['RRCAP', 'stopped', 'cap'],
163      ]
164    : [
165        ['RRSTOP1', 'idle', 'first'],
166        ['RRSTOP2', 'stopped', 'ended'],
167        ['RRSTOP3', 'ender', 'complete'],
168      ];
169
170// Each action is called by name: `claude plugin validate` refuses `$` passed through a dynamic call.
171const act = async ($, action) => {
172  if (action === 'hold') {
173    await tuiHold($);
174  } else if (action === 'peer') {
175    await tuiPeer($);
176  } else if (action === 'cap') {
177    await tuiCap($);
178  } else if (action === 'first') {
179    await stopFirst($);
180  } else if (action === 'ended') {
181    await stopEnded($);
182  } else {
183    await stopComplete($);
184  }
185};
186
187const onMainComplete = async ($) => {
188  const phase = PHASES.find(([marker, from]) => lastText.includes(marker) && state.phase === from);
189  if (!phase) {
190    return;
191  }
192  state.phase = 'acting';
193  try {
194    await act($, phase[2]);
195  } catch (err) {
196    state.error = errOf(err);
197    state.phase = 'error';
198  }
199  await write($);
200};
201
202const AGENTS = [
203  {
204    name: 'holder',
205    description: 'Stop probe agent, held by the proxy. The probe spawns it; do not delegate to it.',
206    prompt: `${HOLD}. Reply with the exact word the task asks for. Do not call tools.`,
207    tools: [],
208    maxTurns: 1,
209  },
210  {
211    name: 'ender',
212    description: 'Stop probe agent that ends by itself. The probe spawns it; do not delegate to it.',
213    prompt: 'Reply with the exact word the task asks for. Do not call tools.',
214    tools: [],
215    maxTurns: 1,
216  },
217  {
218    name: 'capper',
219    description: 'maxTurns probe agent. The probe spawns it; do not delegate to it.',
220    prompt: 'Follow the task. Call Glob when it says to.',
221    tools: ['Glob'],
222    maxTurns: 2,
223  },
224];
225
226export const register = (on) => {
227  on('session.start', async ($, e, next) => {
228    for (const agent of AGENTS) {
229      try {
230        await $.agent.register({ ...agent, model: 'haiku', omitClaudeMd: true, background: true });
231      } catch (err) {
232        state.registerError = errOf(err);
233      }
234    }
235    await write($);
236    return next(e);
237  });
238
239  on('agent.offer', async ($, e, next) =>
240    String(e.agent ?? '').startsWith(`${PLUGIN}:`) ? { isOffered: false } : next(e)
241  );
242
243  on('turn.start', async ($, e, next) => {
244    if (!e.agentId) {
245      lastText = String(e.text ?? '');
246    }
247    return next(e);
248  });
249
250  on('turn.complete', async ($, e, next) => {
251    const result = await next(e);
252    if (!e.agentId) {
253      await onMainComplete($);
254    }
255    return result;
256  });
257};
258