SLOPSHOPPER

wdprobe

Watchdog test fixture, not a mod to install. live probe observer: logs the events it sees and changes none

newrowsguardpromptagents
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/observer.mjs 302 lines
1// The probe's observer: a second plugin (`wdprobe`) loaded beside `plugins/watchdog`. It never changes an event:
2// each hook logs what it sees and passes `e` and the result on unchanged. One JSON object for each line in
3// __LOG__/observer-<instance>.jsonl (a hooks module cannot import node:fs, so it logs with `$.fs.write`; spec §16.3).
4// Two plugins see the same agent events and the synthetic `tool.call` `Agent` of a spawn
5// (research/spawn-sites.md, layout B).
6const LOG_DIR = '__LOG__';
7const INSTANCE = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
8const FILE = `${LOG_DIR}/observer-${INSTANCE}.jsonl`;
9const SKIP =
10  /^(fs|env|clock|store|state|ui\.render|ui\.mount|ui\.invalidate|engine|classic|telemetry)\b|^(command\.describe|tool\.describe|prompt\.attachment|agent\.list|session\.id|session\.messages|session\.usage|session\.measure|config\.list|config\.describe|command\.list)$/u;
11const lines = [];
12let writing = null;
13let isDirty = false;
14let listPoll = 0;
15
16const short = (value, max = 600) => {
17  let text;
18  try {
19    text = typeof value === 'string' ? value : JSON.stringify(value);
20  } catch {
21    text = String(value);
22  }
23  return text !== undefined && text.length > max ? `${text.slice(0, max)}…(+${text.length - max})` : text;
24};
25
26const flush = ($) => {
27  if (writing) {
28    isDirty = true;
29    return writing;
30  }
31  writing = (async () => {
32    do {
33      isDirty = false;
34      await $.fs.write(FILE, `${lines.join('\n')}\n`);
35    } while (isDirty);
36  })()
37    .catch(() => undefined)
38    .finally(() => {
39      writing = null;
40    });
41  return writing;
42};
43
44const log = ($, ev, data) => {
45  lines.push(JSON.stringify({ t: Date.now(), i: INSTANCE, ev, ...data }));
46  return flush($);
47};
48
49const textOf = (content, max) => {
50  const blocks = Array.isArray(content) ? content : [content];
51  return blocks
52    .map((block) => (typeof block === 'string' ? block : block?.type === 'text' ? block.text : ''))
53    .join('\n')
54    .slice(0, max);
55};
56
57const blockKinds = (content) =>
58  (Array.isArray(content) ? content : [content]).map((block) =>
59    typeof block === 'string'
60      ? 'string'
61      : block?.type === 'tool_use'
62        ? `tool_use:${block.name}:${block.id}`
63        : block?.type === 'tool_result'
64          ? `tool_result:${block.tool_use_id}`
65          : block?.type === 'thinking'
66            ? `thinking:${String(block.thinking ?? '').length}`
67            : String(block?.type)
68  );
69
70const snapList = async ($, why) => {
71  try {
72    const list = await $.agent.list();
73    await log($, 'agent.list', { why, list });
74    return list;
75  } catch (error) {
76    await log($, 'agent.list.error', { why, error: String(error?.message ?? error) });
77    return [];
78  }
79};
80
81// First check 2: the `spawnedBy` field shows tens of ms after the spawn resolves, so poll the list for 3 s after
82// each spawn and log each change.
83const pollList = ($, why) => {
84  listPoll += 1;
85  const mine = listPoll;
86  let last = '';
87  const started = Date.now();
88  const tick = async () => {
89    if (mine !== listPoll || Date.now() - started > 3000) {
90      return;
91    }
92    try {
93      const list = await $.agent.list();
94      const text = JSON.stringify(list);
95      if (text !== last) {
96        last = text;
97        await log($, 'agent.list.poll', { why, ms: Date.now() - started, list });
98      }
99    } catch {}
100    setTimeout(tick, 50);
101  };
102  setTimeout(tick, 0);
103};
104
105// §10.7: the notes the engine sends to the model, as the API view of the session shows them.
106const noteMarks = async ($) => {
107  try {
108    const messages = await $.session.messages({ as: 'api' });
109    const marks = [];
110    messages.forEach((message, index) => {
111      const text = JSON.stringify(message.content ?? message);
112      if (text.includes('watchdog-notes')) {
113        marks.push({ index, role: message.role, text: text.slice(0, 1500) });
114      }
115    });
116    return { count: messages.length, marks };
117  } catch (error) {
118    return { error: String(error?.message ?? error) };
119  }
120};
121
122export const register = (on) => {
123  on('*', async ($, e, next) => {
124    const ev = next.event;
125    if (!SKIP.test(ev)) {
126      await log($, 'star', { event: ev, agentId: e?.agentId ?? null, origin: next.origin ?? null });
127    }
128    return next(e);
129  });
130  on('session.start', async ($, e, next) => {
131    await log($, 'session.start', { e: short(e, 800), sessionId: await $.session.id().catch(() => null) });
132    return next(e);
133  });
134  on('session.attach', async ($, e, next) => {
135    await log($, 'session.attach', { e: short(e, 800) });
136    return next(e);
137  });
138  on('session.compact', async ($, e, next) => {
139    const before = await $.session.id().catch(() => null);
140    const result = await next(e);
141    await log($, 'session.compact', { before, after: await $.session.id().catch(() => null) });
142    return result;
143  });
144  on('session.end', async ($, e, next) => {
145    await log($, 'session.end', { e: short(e, 800) });
146    await flush($);
147    return next(e);
148  });
149  on('command.run', async ($, e, next) => {
150    const before = await $.session.id().catch(() => null);
151    await log($, 'command.run.in', { command: e.command, args: e.args, origin: e.origin ?? null, sessionId: before });
152    const result = await next(e);
153    await log($, 'command.run.out', {
154      command: e.command,
155      args: e.args,
156      text: short(result?.text ?? null, 3000),
157      sessionId: await $.session.id().catch(() => null),
158    });
159    return result;
160  });
161  on('agent.register', async ($, e, next) => {
162    const result = await next(e).catch(async (error) => {
163      await log($, 'agent.register.error', { name: e.name, error: String(error?.message ?? error) });
164      throw error;
165    });
166    await log($, 'agent.register', {
167      name: e.name,
168      model: e.model,
169      effort: e.effort,
170      tools: e.tools,
171      maxTurns: e.maxTurns,
172      origin: next.origin ?? null,
173      result: short(result, 300),
174    });
175    return result;
176  });
177  on('agent.spawn', async ($, e, next) => {
178    await log($, 'agent.spawn.in', {
179      subagentType: e.subagentType,
180      model: e.model ?? null,
181      background: e.background ?? null,
182      tool_use_id: e.tool_use_id ?? null,
183      parentAgentId: e.parentAgentId ?? null,
184      isTeammate: e.isTeammate ?? null,
185      origin: next.origin ?? null,
186      prompt: short(e.prompt ?? '', 4000),
187    });
188    const result = await next(e);
189    await log($, 'agent.spawn.out', { subagentType: e.subagentType, result });
190    pollList($, `spawn ${result?.agentId ?? '?'}`);
191    return result;
192  });
193  on('tool.call', async ($, e, next) => {
194    const { tool, agentId, tool_use_id: toolUseId, ...input } = e;
195    await log($, 'tool.call.in', {
196      tool,
197      agentId: agentId ?? null,
198      tool_use_id: toolUseId ?? null,
199      keys: Object.keys(e),
200      origin: next.origin ?? null,
201      input: short(input, 1500),
202    });
203    const result = await next(e);
204    await log($, 'tool.call.out', {
205      tool,
206      agentId: agentId ?? null,
207      tool_use_id: toolUseId ?? null,
208      result: short(result, 1500),
209    });
210    return result;
211  });
212  on('tool.check', async ($, e, next) => {
213    const result = await next(e);
214    await log($, 'tool.check', {
215      tool: e.tool,
216      agentId: e.agentId ?? null,
217      tool_use_id: e.tool_use_id ?? null,
218      origin: next.origin ?? null,
219      ceiling: e.ceiling ?? null,
220      input: short({ ...e, tool: undefined, agentId: undefined }, 600),
221      result: short(result, 600),
222    });
223    return result;
224  });
225  on('session.append', async ($, e, next) => {
226    const message = e.message ?? {};
227    await log($, 'session.append', {
228      agentId: e.agentId ?? null,
229      door: e.door ?? null,
230      origin: e.origin ?? null,
231      uuid: e.uuid ?? null,
232      type: message.type ?? null,
233      blocks: blockKinds(message.content),
234      text: textOf(message.content, 2000),
235    });
236    return next(e);
237  });
238  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
239    await log($, 'ui.render.CommandOutput', {
240      command: e.props?.command ?? null,
241      args: e.props?.args ?? null,
242      isErrored: e.props?.isErrored ?? null,
243      requestId: e.requestId ?? null,
244      text: short(e.props?.text ?? null, 600),
245    });
246    return next(e);
247  });
248  on('prompt.submit', async ($, e, next) => {
249    await log($, 'prompt.submit', {
250      text: short(e.text ?? '', 600),
251      origin: e.origin ?? null,
252      agentId: e.agentId ?? null,
253      context: (e.context ?? []).map((item) => short(item, 600)),
254    });
255    return next(e);
256  });
257  on('turn.start', async ($, e, next) => {
258    await log($, 'turn.start', {
259      agentId: e.agentId ?? null,
260      turnId: e.turnId ?? null,
261      text: short(e.text ?? '', 400),
262    });
263    return next(e);
264  });
265  on('turn.step', async function* ($, e, next) {
266    await log($, 'turn.step', {
267      agentId: e.agentId ?? null,
268      index: e.index,
269      model: e.model ?? null,
270      messageCount: e.messageCount ?? null,
271    });
272    return yield* next(e);
273  });
274  on('turn.complete', async ($, e, next) => {
275    const result = await next(e);
276    const isMain = !e.agentId;
277    await log($, 'turn.complete', {
278      agentId: e.agentId ?? null,
279      turnId: e.turnId ?? null,
280      reason: e.reason ?? null,
281      isAborted: e.isAborted ?? null,
282      answer: short(e.answer ?? '', 600),
283      usage: e.usage ?? null,
284      ...(isMain
285        ? { sessionId: await $.session.id().catch(() => null), notes: await noteMarks($) }
286        : { agentType: e.agentType ?? null }),
287    });
288    if (isMain) {
289      await snapList($, 'main turn.complete');
290    }
291    return result;
292  });
293  on('ui.log', async ($, e, next) => {
294    await log($, 'ui.log', { text: short(e.text ?? e, 2000), origin: next.origin ?? null });
295    return next(e);
296  });
297  on('config.set', async ($, e, next) => {
298    await log($, 'config.set', { e: short(e, 600), origin: next.origin ?? null });
299    return next(e);
300  });
301};
302