Watchdog test fixture, not a mod to install. first-check probe: a slow agent for the 30s summary fork, and register timing before session.start

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/fork.mjs 136 lines1// First checks 3 and 6 (spec §16.4), from prototypes/first-checks/spawn-id and prototypes/first-checks/register.
2// Item 3: a plugin agent that stays alive past 30 s (short Bash sleeps, one per response), so the TUI forks an
3// [AgentSummary] agent (the debug file shows it) and -p does not; two `$.agent.list()` reads at +31 s and +34 s show
4// whether the fork joins the list. Item 6: the events before `session.start`, a register at the first lifecycle event
5// (command.describe, ui.render or session.start), what `register` gets at load (no `$`), and a register and a spawn
6// in one `prompt.submit` handler.
7const FILE = '__LOG__/fc-fork.jsonl';
8const TYPE = 'fcfork:slow';
9const SPEC = {
10 name: 'slow',
11 description: 'Slow probe agent for the summary-fork check.',
12 prompt: 'You are a terse probe agent. Follow the task exactly. Do not summarize your progress.',
13 tools: ['Bash'],
14 model: 'haiku',
15 effort: 'low',
16 omitClaudeMd: true,
17 background: true,
18};
19const SLOW =
20 'Work slowly. One tool call per response, never parallel calls. Do 8 rounds. In each round run the Bash ' +
21 'command "sleep 4" and nothing else. After round 8 reply DONE.';
22// §4.5: the events the spec orders: the first TUI event is command.describe, the first -p event session.start.
23const LIFECYCLE = new Set(['command.describe', 'ui.render', 'session.start']);
24// §6.4: list reads after the 30 s fork time.
25const LIST_AT_MS = [31_000, 34_000];
26const lines = [];
27const before = [];
28let writing = null;
29let isStarted = false;
30let isFirstRegistered = false;
31let isSessionStarted = false;
32
33// Keeps a line in memory; `flush` writes them all (a hooks module cannot import node:fs, spec §16.3).
34const rec = (ev, data = {}) => {
35 lines.push(JSON.stringify({ t: Date.now(), ev, ...data }));
36};
37
38const flush = ($) => {
39 const text = `${lines.join('\n')}\n`;
40 writing = (writing ?? Promise.resolve()).then(() => $.fs.write(FILE, text)).catch(() => undefined);
41 return writing;
42};
43
44const log = ($, ev, data) => {
45 rec(ev, data);
46 return flush($);
47};
48
49const errorText = (error) => String(error?.message ?? error);
50
51// §6.4: the fork never shows in the list; each entry's `id` and `spawnedBy`.
52const readList = async ($, atMs) => {
53 const list = await $.agent.list().then(
54 (value) => ({ entries: value.map((agent) => ({ id: agent.id, type: agent.type, spawnedBy: agent.spawnedBy })) }),
55 (error) => ({ error: errorText(error) })
56 );
57 await log($, 'list', { atMs, ...list });
58};
59
60export const register = (on, ...rest) => {
61 // §4.5 item 6: no `$` at load; `register` gets `on` and the plugin's options only.
62 rec('load', {
63 args: rest.map((arg) => (arg && typeof arg === 'object' ? Object.keys(arg) : typeof arg)),
64 hasGlobalDollar: typeof globalThis.$ !== 'undefined',
65 });
66 on('*', async ($, e, next) => {
67 const ev = next.event;
68 if (ev === 'engine.create') {
69 // §4.5 item 6: no `$` exists at load; a `$` call here throws (2.1.290: a TypeError).
70 const call = await Promise.resolve()
71 .then(() => $.session.id())
72 .then(
73 () => 'resolved',
74 (error) => errorText(error)
75 );
76 rec('engine.create', { call });
77 return next(e);
78 }
79 if (ev.startsWith('fs.')) {
80 return next(e);
81 }
82 if (!isSessionStarted && !before.includes(ev)) {
83 before.push(ev);
84 }
85 // §4.5 item 6: a register before the session binds does not reject; not awaited, so it cannot hold the event.
86 if (!isFirstRegistered && LIFECYCLE.has(ev)) {
87 isFirstRegistered = true;
88 const began = Date.now();
89 rec('first.register.call', { event: ev });
90 $.agent.register(SPEC).then(
91 (result) => rec('first.register.ok', { event: ev, ms: Date.now() - began, result }),
92 (error) => rec('first.register.err', { event: ev, ms: Date.now() - began, error: errorText(error) })
93 );
94 }
95 return next(e);
96 });
97 on('session.start', async ($, e, next) => {
98 isSessionStarted = true;
99 // The `*` hook may run after this one for the same event.
100 if (!before.includes('session.start')) {
101 before.push('session.start');
102 }
103 rec('order', { events: before });
104 const result = await $.agent.register(SPEC).then(
105 (value) => ({ ok: true, value: value ?? null }),
106 (error) => ({ ok: false, error: errorText(error) })
107 );
108 await log($, 'register', { where: 'session.start', ...result });
109 return next(e);
110 });
111 on('agent.offer', { agent: TYPE }, async () => ({ isOffered: false }));
112 on('prompt.submit', async ($, e, next) => {
113 if (String(e.text ?? '').includes('WDGO') && !isStarted) {
114 isStarted = true;
115 // §5.2, §16.4 item 6: a register and a spawn in the same handler.
116 const registered = await $.agent.register(SPEC).then(
117 (value) => ({ ok: true, value: value ?? null }),
118 (error) => ({ ok: false, error: errorText(error) })
119 );
120 const calledAt = Date.now();
121 await log($, 'spawn.call', { type: TYPE, registered });
122 $.agent.spawn({ subagentType: TYPE, description: 'summary fork probe', prompt: SLOW }).then(
123 (result) => {
124 LIST_AT_MS.forEach((ms) => {
125 setTimeout(() => readList($, ms), Math.max(0, calledAt + ms - Date.now()));
126 });
127 return log($, 'spawn.resolved', { agentId: result?.agentId ?? null, ms: Date.now() - calledAt });
128 },
129 (error) => log($, 'spawn.rejected', { error: errorText(error) })
130 );
131 }
132 await flush($);
133 return next(e);
134 });
135};
136