Watchdog test fixture, not a mod to install. live probe: parent permission mode and Edit versus Write

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/perm.mjs 134 lines1// §16.5 roster and tools (research/smoke-mutating-tools.md Q3, Q4 and Gaps). installMod fills __LOG__ and __TOOLS__
2// (`yes` in one session only). At the first main turn.complete it spawns:
3// - `editor` (tools Read, Edit, Write; permissionMode dontAsk): an Edit of math.js, then a Write of rr-wrote.txt. With
4// a `default` parent the agent's dontAsk denies both; an acceptEdits-like parent (bypassPermissions, auto) wins
5// over the agent's mode, so both run. Its tool.check verdicts and tool.call results are recorded.
6// - with __TOOLS__ = yes, `listed` (tools Read, Glob; no disallowedTools) and `denied` (tools Read, Glob, Bash,
7// Edit, Write; disallowedTools Bash, Edit, Write). Their system prompts carry RRTOOLS-LISTED and RRTOOLS-DENIED,
8// so the proxy's saved request bodies show each one's tool list.
9// Result: __LOG__/rr-perm.json.
10const FILE = '__LOG__/rr-perm.json';
11const PLUGIN = 'rrperm';
12const TOOLS = '__TOOLS__' === 'yes';
13const EDITED = new Set(['Edit', 'Write']);
14
15const state = { phase: 'idle', checks: [], calls: [], spawns: {} };
16let writing = Promise.resolve();
17
18const errOf = (err) => ({ name: err?.name ?? 'Error', message: String(err?.message ?? err).slice(0, 800) });
19
20const write = ($) => {
21 writing = writing.then(() => $.fs.write(FILE, JSON.stringify(state))).catch(() => undefined);
22 return writing;
23};
24
25// The Read first: Edit refuses a file it has not read before its permission check runs, and that is not this probe.
26const EDIT_TASK =
27 'Do these three tool calls, in this order. Do not retry a refusal. ' +
28 '1. Read math.js. ' +
29 '2. Edit math.js: replace the first line `export function add(a, b) {` with ' +
30 '`export function add(a, b) { // RRPERM`. ' +
31 '3. Write the file rr-wrote.txt with the contents hi. ' +
32 'Then reply DONE.';
33
34const AGENTS = [
35 {
36 name: 'editor',
37 description: 'Permission probe agent. The probe spawns it; do not delegate to it.',
38 prompt: 'Follow the task. Do not retry a refused tool.',
39 tools: ['Read', 'Edit', 'Write'],
40 permissionMode: 'dontAsk',
41 maxTurns: 6,
42 },
43 {
44 name: 'listed',
45 description: 'Tool-list probe agent. The probe spawns it; do not delegate to it.',
46 prompt: 'RRTOOLS-LISTED. Reply with exactly OK. Do not call tools.',
47 tools: ['Read', 'Glob'],
48 maxTurns: 1,
49 },
50 {
51 name: 'denied',
52 description: 'Tool-list probe agent. The probe spawns it; do not delegate to it.',
53 prompt: 'RRTOOLS-DENIED. Reply with exactly OK. Do not call tools.',
54 tools: ['Read', 'Glob', 'Bash', 'Edit', 'Write'],
55 disallowedTools: ['Bash', 'Edit', 'Write'],
56 maxTurns: 1,
57 },
58];
59
60const spawn = async ($, name, prompt) => {
61 try {
62 const resolved = await $.agent.spawn({ subagentType: `${PLUGIN}:${name}`, description: `probe ${name}`, prompt });
63 return { agentId: resolved?.agentId ?? null, resolved };
64 } catch (err) {
65 return { agentId: null, rejected: errOf(err) };
66 }
67};
68
69export const register = (on) => {
70 on('session.start', async ($, e, next) => {
71 for (const agent of TOOLS ? AGENTS : AGENTS.slice(0, 1)) {
72 try {
73 await $.agent.register({ ...agent, model: 'haiku', omitClaudeMd: true, background: true });
74 } catch (err) {
75 state.registerError = errOf(err);
76 }
77 }
78 return next(e);
79 });
80
81 on('agent.offer', async ($, e, next) =>
82 String(e.agent ?? '').startsWith(`${PLUGIN}:`) ? { isOffered: false } : next(e)
83 );
84
85 // The engine's verdict for the editor's Edit and Write (a bypassPermissions parent may ask no question at all).
86 on('tool.check', async ($, e, next) => {
87 const result = await next(e);
88 if (e.agentId && EDITED.has(e.tool)) {
89 state.checks.push({
90 tool: e.tool,
91 agentId: e.agentId,
92 decision: result?.decision ?? null,
93 reason: String(result?.reason ?? '').slice(0, 300),
94 });
95 await write($);
96 }
97 return result;
98 });
99
100 // What the editor's Edit and Write answered.
101 on('tool.call', async ($, e, next) => {
102 const result = await next(e);
103 if (e.agentId && EDITED.has(e.tool)) {
104 state.calls.push({
105 tool: e.tool,
106 agentId: e.agentId,
107 isError: result?.isError === true || result?.deny !== undefined,
108 text: String(result?.text ?? result?.deny ?? '').slice(0, 300),
109 });
110 await write($);
111 }
112 return result;
113 });
114
115 on('turn.complete', async ($, e, next) => {
116 const result = await next(e);
117 if (!e.agentId && state.phase === 'idle') {
118 state.phase = 'spawning';
119 state.spawns.editor = await spawn($, 'editor', EDIT_TASK);
120 if (TOOLS) {
121 state.spawns.listed = await spawn($, 'listed', 'Reply with exactly OK.');
122 state.spawns.denied = await spawn($, 'denied', 'Reply with exactly OK.');
123 }
124 state.phase = 'spawned';
125 await write($);
126 } else if (e.agentId && e.agentId === state.spawns.editor?.agentId) {
127 state.editorEnd = { reason: e.reason ?? null, answer: String(e.answer ?? '').slice(0, 400) };
128 state.phase = 'done';
129 await write($);
130 }
131 return result;
132 });
133};
134