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

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/observer.mjs 302 lines1// 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