Watchdog test fixture, not a mod to install. live probe: spawn sites the spec leaves open

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/spawn.mjs 181 lines1// §16.5 spawn sites (spec §7.2, §10.3; research/spawn-sites.md "Not checked"). One -p session that stream-json input
2// keeps open. installMod fills __LOG__. Four sites, each spawning one `reviewer` (tool Glob):
3// step the main turn.step hook at index >= 1, awaited before the hook returns (a live frame);
4// tool the tool.call hook of the main loop's Read, awaited before the hook returns (a live frame);
5// busy a $.clock.after callback armed at main step 0, so it spawns and submits while that turn runs;
6// idle a $.clock.after callback 3 s after the last main turn.complete, while -p is idle but still open.
7// The `on('*')` registration is the witness: it counts each agent's events that reach this module through another
8// registration than the spawning one. `stepHook`, `toolHook` and `completeHook` count what the turn.step, tool.call
9// and turn.complete registrations themselves saw of each agent. Result: __LOG__/rr-spawn.json.
10const FILE = '__LOG__/rr-spawn.json';
11const PLUGIN = 'rrspawn';
12const BUSY_MS = 400;
13const IDLE_MS = 3000;
14const IDLE_TRIES = 20;
15const SUBMITS = { busy: 'Reply with the single word CLOCKED.', idle: 'Reply with the single word IDLED.' };
16
17const state = {
18 phase: 'idle',
19 turnRunning: false,
20 mainStarts: 0,
21 mainCompletes: 0,
22 sites: {},
23 ends: {},
24 witness: {},
25 stepHook: {},
26 toolHook: {},
27 completeHook: {},
28};
29let idleTimer = null;
30let idleTries = 0;
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
40const bump = (map, agentId, key = 'count') => {
41 map[agentId] = map[agentId] ?? {};
42 map[agentId][key] = (map[agentId][key] ?? 0) + 1;
43};
44
45const spawn = async ($, tag) => {
46 const at = Date.now();
47 try {
48 const resolved = await $.agent.spawn({
49 subagentType: `${PLUGIN}:reviewer`,
50 description: `probe ${tag}`,
51 prompt: `Call the Glob tool once with pattern "*.txt". Then reply with exactly DONE-${tag}.`,
52 });
53 return { tag, at, agentId: resolved?.agentId ?? null, resolved };
54 } catch (err) {
55 return { tag, at, agentId: null, rejected: errOf(err) };
56 }
57};
58
59// The busy and idle sites: a bare frame (§7.2), so no hook of this plugin sees what these calls cause.
60const clockSite = async ($, tag) => {
61 const duringTurn = state.turnRunning;
62 const spawned = await spawn($, tag);
63 let submit;
64 try {
65 submit = { resolved: await $.prompt.submit({ text: SUBMITS[tag] }) };
66 } catch (err) {
67 submit = { rejected: errOf(err) };
68 }
69 state.sites[tag] = { ...spawned, duringTurn, submit, doneAt: Date.now() };
70 await write($);
71};
72
73// §10.3: the -p idle case. Re-armed while a main turn runs (the busy submit can start one), at most IDLE_TRIES times.
74const onIdle = async ($) => {
75 if (state.sites.idle) {
76 return;
77 }
78 if (state.turnRunning && idleTries < IDLE_TRIES) {
79 idleTries += 1;
80 idleTimer = $.clock.after(IDLE_MS, () => onIdle($));
81 return;
82 }
83 await clockSite($, 'idle');
84};
85
86const armIdle = ($) => {
87 if (state.sites.idle) {
88 return;
89 }
90 idleTimer?.cancel();
91 idleTimer = $.clock.after(IDLE_MS, () => onIdle($));
92};
93
94export const register = (on) => {
95 on('session.start', async ($, e, next) => {
96 try {
97 await $.agent.register({
98 name: 'reviewer',
99 description: 'Spawn-site probe agent. The probe spawns it; do not delegate to it.',
100 prompt: 'Do the task in as few steps as it says. Use only the tool it names.',
101 tools: ['Glob'],
102 model: 'haiku',
103 omitClaudeMd: true,
104 background: true,
105 maxTurns: 3,
106 });
107 } catch (err) {
108 state.registerError = errOf(err);
109 }
110 await write($);
111 return next(e);
112 });
113
114 on('agent.offer', async ($, e, next) =>
115 String(e.agent ?? '').startsWith(`${PLUGIN}:`) ? { isOffered: false } : next(e)
116 );
117
118 // The witness: every event that carries an agent's id and reaches this registration.
119 on('*', async ($, e, next) => {
120 if (typeof e?.agentId === 'string' && e.agentId !== '') {
121 bump(state.witness, e.agentId, next.event);
122 }
123 return next(e);
124 });
125
126 on('turn.start', async ($, e, next) => {
127 if (!e.agentId) {
128 state.turnRunning = true;
129 state.mainStarts += 1;
130 }
131 return next(e);
132 });
133
134 // §7.2: the step site spawns after its step's stream ended, before the hook returns.
135 on('turn.step', async function* ($, e, next) {
136 if (e.agentId) {
137 bump(state.stepHook, e.agentId);
138 return yield* next(e);
139 }
140 const result = yield* next(e);
141 if (e.index === 0 && !state.busyArmed) {
142 state.busyArmed = true;
143 $.clock.after(BUSY_MS, () => clockSite($, 'busy'));
144 }
145 if (e.index >= 1 && !state.sites.step) {
146 state.sites.step = { ...(await spawn($, 'step')), index: e.index };
147 await write($);
148 }
149 return result;
150 });
151
152 // The tool site: the main loop's Read, after its result, before the hook returns.
153 on('tool.call', async ($, e, next) => {
154 if (e.agentId) {
155 bump(state.toolHook, e.agentId);
156 return next(e);
157 }
158 const result = await next(e);
159 if (e.tool === 'Read' && !state.sites.tool) {
160 state.sites.tool = await spawn($, 'tool');
161 await write($);
162 }
163 return result;
164 });
165
166 on('turn.complete', async ($, e, next) => {
167 const result = await next(e);
168 if (e.agentId) {
169 bump(state.completeHook, e.agentId);
170 const answer = String(e.answer ?? '').slice(0, 200);
171 state.ends[e.agentId] = { reason: e.reason ?? null, answer, at: Date.now() };
172 } else {
173 state.turnRunning = false;
174 state.mainCompletes += 1;
175 armIdle($);
176 }
177 await write($);
178 return result;
179 });
180};
181