Watchdog test fixture, not a mod to install. first-check probe: hold the main Agent tool.call 8s and return context, or spin the hooks worker for the .catch

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

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.
/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.
plugins/watchdog/hooks/.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./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.<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.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.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.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.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./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./watchdog status shows both counts, for example nudge 1/1 · blocker 0/2./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).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" }] }
claude -p needs CLAUDE_WATCHDOG=on and has no nudge and no cards: see docs/headless.md.plugins/watchdog/hooks/prices.ts); a model not in it shows $?.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.
Apache-2.0
hooks/hold.mjs 122 lines1// First check 4 (spec §16.4), adapted from prototypes/first-checks/hold. The main-loop `tool.call` for `Agent`, after
2// `next(e)` resolved:
3// - mode hold: wait 8 s inside the 10 s budget (`next.signal` ends the wait at Esc), then return `context`;
4// - mode spin: keep the hooks worker busy for 12 s without yielding, so the engine replaces the worker and asks the
5// `.catch` in the new one (§8.3); the `.catch` returns its own `context`.
6// The mode comes from the last person prompt: one with `FCSPIN-` spins, one with `FCHOLD-` holds. A worker respawn
7// loads the module again, so each module instance logs to its own file `fc-hold-<instance>.jsonl`.
8const INSTANCE = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
9const FILE = `__LOG__/fc-hold-${INSTANCE}.jsonl`;
10const HOLD_MARKER = 'FCHOLD-ZEBRA8';
11const SPIN_HOOK_MARKER = 'FCSPIN-HOOK0';
12const CATCH_MARKER = 'FCSPIN-CATCH7';
13const HOLD_MS = 8000;
14const SPIN_MS = 12_000;
15const lines = [];
16let writing = null;
17let mode = 'hold';
18
19const log = ($, ev, data) => {
20 lines.push(JSON.stringify({ t: Date.now(), inst: INSTANCE, ev, ...data }));
21 const text = `${lines.join('\n')}\n`;
22 writing = (writing ?? Promise.resolve()).then(() => $.fs.write(FILE, text)).catch(() => undefined);
23 return writing;
24};
25
26const reasonOf = (signal) => {
27 const reason = signal?.reason;
28 return reason === undefined ? null : String(reason?.message ?? reason);
29};
30
31// The prototype's framing, so the primary reads the word as a plugin's test word.
32const codeWord = (word) => `Test code word from the probe plugin: ${word}`;
33
34// §11.3: what the engine answered the Agent call with (a foreground report, or the launch ack of a background run).
35const resultFacts = (result) => ({
36 isAsync: result?.result?.isAsync ?? null,
37 status: result?.result?.status ?? null,
38 text: String(result?.text ?? '').slice(0, 120),
39});
40
41const isMainAgentCall = (e) =>
42 e.tool === 'Agent' && !e.agentId && !String(e.tool_use_id ?? '').startsWith('toolu_plugin_');
43
44export const register = (on) => {
45 on('prompt.submit', async ($, e, next) => {
46 const text = String(e.text ?? '');
47 if (text.includes('FCSPIN-')) {
48 mode = 'spin';
49 } else if (text.includes('FCHOLD-')) {
50 mode = 'hold';
51 }
52 return next(e);
53 });
54 // §11.3: whether the engine runs the main Agent call in the background.
55 on('agent.spawn', async ($, e, next) => {
56 const result = await next(e);
57 if (!e.parentAgentId) {
58 await log($, 'spawn', {
59 subagentType: e.subagentType,
60 background: e.background ?? null,
61 agentId: result?.agentId ?? null,
62 });
63 }
64 return result;
65 });
66 on('tool.call', async ($, e, next) => {
67 if (!isMainAgentCall(e)) {
68 return next(e);
69 }
70 const id = e.tool_use_id;
71 const how = mode;
72 await log($, 'call.start', {
73 id,
74 mode: how,
75 keys: Object.keys(e),
76 runInBackground: e.run_in_background ?? null,
77 remainingMs: next.budget?.remainingMs ?? null,
78 });
79 const began = Date.now();
80 const result = await next(e);
81 const nextMs = Date.now() - began;
82 const left = next.budget?.remainingMs ?? null;
83 await log($, 'call.next', { id, mode: how, nextMs, remainingMs: left, ...resultFacts(result) });
84 if (how === 'spin') {
85 const end = Date.now() + SPIN_MS;
86 while (Date.now() < end) {
87 // §8.3: a hook that never yields; the heartbeat replaces the worker after 5 s.
88 }
89 await log($, 'spin.end', { id });
90 return { ...result, context: [codeWord(SPIN_HOOK_MARKER)] };
91 }
92 let ended = 'slept';
93 try {
94 await $.clock.sleep(HOLD_MS, { signal: next.signal });
95 } catch (error) {
96 ended = `rejected: ${error?.name}: ${error?.message}`;
97 }
98 await log($, 'hold.end', {
99 id,
100 how: ended,
101 heldMs: Date.now() - began - nextMs,
102 remainingMs: next.budget?.remainingMs ?? null,
103 aborted: next.signal?.aborted ?? null,
104 reason: reasonOf(next.signal),
105 });
106 return { ...result, context: [codeWord(HOLD_MARKER)] };
107 }).catch(async ($, e, next) => {
108 await log($, 'catch', {
109 id: e.tool_use_id ?? null,
110 tool: e.tool,
111 called: next.called ?? null,
112 error: String(next.error?.message ?? next.error ?? '').slice(0, 200),
113 });
114 if (!isMainAgentCall(e)) {
115 return next(e);
116 }
117 const result = await next(e);
118 await log($, 'catch.answer', { id: e.tool_use_id ?? null, ...resultFacts(result) });
119 return { ...result, context: [codeWord(CATCH_MARKER)] };
120 });
121};
122