Watchdog agents review each update of the agent you work with and push short notes to it. It spawns review subagents and auto-allows only its own review spawn…

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/register.ts 45 lines1import { installAgents } from './agents/install';
2import { installBand } from './band/install';
3import { installCommand } from './command/install';
4import { installDelivery } from './delivery/install';
5import { installDump } from './dump/install';
6import { installFailure } from './failure/install';
7import { installFeed } from './feed/install';
8import { installGuidance } from './guidance/install';
9import { installLifecycle } from './lifecycle/install';
10import { installLog } from './log/install';
11import { installNote } from './note/install';
12import { installReview } from './review/install';
13import { installRewind } from './rewind/install';
14import { installRoster } from './roster/install';
15import { installSession } from './session/install';
16import { installStatus } from './status/install';
17import { installStop } from './stop/install';
18import { installStore } from './store/install';
19import { installSubagents } from './subagents/install';
20import { installTools } from './tools/install';
21import type { Register } from 'claude-code';
22
23export const register: Register = (on, options) => {
24 installStore(on);
25 installBand(on);
26 installSession(on);
27 installRewind(on);
28 installRoster(on);
29 installFailure(on);
30 installStop(on);
31 installStatus(on);
32 installCommand(on, options);
33 installDump(on);
34 installLifecycle(on);
35 installGuidance(on);
36 installAgents(on);
37 installFeed(on);
38 installReview(on);
39 installLog(on);
40 installNote(on);
41 installSubagents(on);
42 installDelivery(on, options);
43 installTools(on);
44};
45hooks/agents/install.ts 91 lines1import { rememberSpawnModel } from '../failure/state';
2import { isIdMissing, learnReviewAgent } from '../review/slots';
3import { crossCheck, needsCrossCheck, ownContext, watchdogIds } from './ids';
4import { isOwnSpawn, isOwnToolCall } from './self-review';
5import { TYPE_PREFIX } from './spec';
6import type { OnEvents } from '../on';
7import type { EngineInterface, Hook, MatchedHook } from 'claude-code';
8
9// §7.3: `$.state` keeps a copy of the id set, an array because a Set becomes `{}` in JSON. The live copy is
10// module memory, so a refused write loses only what a reload would carry over.
11const saveIds = async ($: EngineInterface): Promise<void> => {
12 await $.state.set({ plugin: 'watchdog', key: 'ids' }, watchdogIds()).catch(() => undefined);
13};
14
15// The agent id in an `Agent` call's result record.
16const agentIdOf = (record: unknown): string | undefined =>
17 typeof record === 'object' && record !== null && 'agentId' in record && typeof record.agentId === 'string'
18 ? record.agentId
19 : undefined;
20
21const learn = async ($: EngineInterface, subagentType: unknown, agentId: string | undefined): Promise<void> => {
22 if (agentId !== undefined && isOwnSpawn(subagentType)) {
23 learnReviewAgent(subagentType.slice(TYPE_PREFIX.length), agentId);
24 await saveIds($);
25 }
26};
27
28// §7.3: the id comes from the `next(e)` result of the mod's `agent.spawn` hook, before the spawn resolves.
29// §12.2: so does the model that the agent runs on.
30const onSpawn: Hook<'agent.spawn'> = async ($, e, next) => {
31 const result = await next(e);
32 if (result.agentId !== undefined) {
33 rememberSpawnModel(result.agentId, result.model);
34 }
35 await learn($, e.subagentType, result.agentId);
36 return result;
37};
38
39// §7.3: or from the `next(e)` result of the synthetic main-loop `Agent` call of the spawn.
40const onAgentCall: MatchedHook<'tool.call', { tool: 'Agent' }> = async ($, e, next) => {
41 if (e.agentId !== undefined || !isOwnToolCall(e, ownContext())) {
42 return next(e);
43 }
44 const result = await next(e);
45 const isAnswered = result.deny === undefined && result.isError !== true;
46 await learn($, e.subagent_type, isAnswered ? agentIdOf(result.result) : undefined);
47 return result;
48};
49
50// §7.3: `$.agent.list()` is a later cross-check only. While a review waits for its agent's id, an agent id that no
51// spawn source gave is the mod's once the list shows that the mod spawned it. A refused list answers nothing (in
52// `-p` it rejects inside the agent's own `turn.complete`), so the agent's next event asks again.
53const crossCheckId = async ($: EngineInterface, agentId: string | undefined): Promise<void> => {
54 if (agentId === undefined || !isIdMissing() || !needsCrossCheck(agentId)) {
55 return;
56 }
57 const agents = await $.agent.list().catch(() => undefined);
58 await learn($, agents === undefined ? undefined : crossCheck(agentId, agents), agentId);
59};
60
61// §7.3: each event of an agent loop that the hooks beneath read by the id set: its steps, its tool calls (the
62// `note` call too) and its end.
63const onLoopStep: Hook<'turn.step'> = async function* ($, e, next) {
64 await crossCheckId($, e.agentId);
65 return yield* next(e);
66};
67
68const onLoopCall: MatchedHook<'tool.call', { agentId: RegExp }> = async ($, e, next) => {
69 await crossCheckId($, e.agentId);
70 return next(e);
71};
72
73const onLoopEnd: MatchedHook<'turn.complete', { agentId: RegExp }> = async ($, e, next) => {
74 await crossCheckId($, e.agentId);
75 return next(e);
76};
77
78// §6.1: hide each watchdog type from the model; the hide does not block the mod's own spawn.
79export const installAgents = (
80 on: OnEvents<'agent.offer' | 'agent.spawn' | 'tool.call' | 'turn.step' | 'turn.complete'>
81): void => {
82 on('agent.offer', { agent: /^watchdog:/u }, () => ({ isOffered: false })).catch((_$, e, next) =>
83 next.called ? next(e) : { isOffered: false }
84 );
85 on('agent.spawn', { subagentType: /^watchdog:/u }, onSpawn);
86 on('tool.call', { tool: 'Agent' }, onAgentCall);
87 on('tool.call', { agentId: /^/u }, onLoopCall);
88 on('turn.step', { turnId: /^/u }, onLoopStep);
89 on('turn.complete', { agentId: /^/u }, onLoopEnd);
90};
91hooks/band/install.ts 165 lines1import { currentRoster } from '../agents/roster';
2import { currentTurn } from '../delivery/turns';
3import { currentMode } from '../lifecycle/mode';
4import { isInteractiveSession } from '../lifecycle/on-order';
5import { watchHeldNotes } from '../note/notes';
6import { isPersonPrompt } from '../person';
7import { EMPTY_BAND, bandState, changeCard, clearCards, restoreBand, toggleCard } from './cards';
8import { troubleLine } from './problems';
9import { bandTree } from './tree';
10import type { OnEvents } from '../on';
11import type { Trouble } from './problems';
12import type { EngineInterface, Hook, MatchedHook, PluginState, RenderElement } from 'claude-code';
13
14type BandHook = MatchedHook<'ui.render', { component: 'AbovePrompt' }>;
15
16type Health = PluginState['watchdog']['health'];
17
18// The band value this module instance last wrote to `$.state`, as JSON; whether it read the band back (§14.6).
19const memory: { written: string | undefined; isLoaded: boolean } = { written: undefined, isLoaded: false };
20
21// §13.1: a `$.state` write draws the band again; only a band that changed is written.
22const writeBand = async ($: EngineInterface): Promise<void> => {
23 const band = bandState(currentTurn());
24 const json = JSON.stringify(band);
25 if (json === memory.written) {
26 return;
27 }
28 memory.written = json;
29 await $.state.set({ plugin: 'watchdog', key: 'band' }, band).catch(() => {
30 memory.written = undefined;
31 });
32};
33
34// The note hook and the delivery hooks beneath change the cards; each of these hooks writes the band after them.
35const afterTool: Hook<'tool.call'> = async ($, e, next) => {
36 const result = await next(e);
37 await writeBand($);
38 return result;
39};
40
41const afterTurnStart: Hook<'turn.start'> = async ($, e, next) => {
42 const result = await next(e);
43 await writeBand($);
44 return result;
45};
46
47const afterTurnComplete: Hook<'turn.complete'> = async ($, e, next) => {
48 const result = await next(e);
49 await writeBand($);
50 return result;
51};
52
53// §13.1: a person prompt clears the cards; the count line then shows no open note.
54const onPrompt: Hook<'prompt.submit'> = async ($, e, next) => {
55 if (isPersonPrompt(e.origin)) {
56 clearCards();
57 }
58 const result = await next(e);
59 await writeBand($);
60 return result;
61};
62
63// §5.2 step 2: `/watchdog off` clears the cards.
64const afterCommand: Hook<'command.run'> = async ($, e, next) => {
65 const result = await next(e);
66 if (currentMode() === 'off') {
67 clearCards();
68 }
69 await writeBand($);
70 return result;
71};
72
73// §13.1: a press on a card's Button expands or collapses it beneath, in its `onPress`; the band is written after.
74const afterPress: Hook<'ui.press'> = async ($, e, next) => {
75 const result = await next(e);
76 await writeBand($);
77 return result;
78};
79
80// §14.6: at module load the cards come back from `$.state`, so a reload keeps the band.
81const onSessionStart: Hook<'session.start'> = async ($, e, next) => {
82 const result = await next(e);
83 if (!memory.isLoaded) {
84 memory.isLoaded = true;
85 const stored = await $.state.get({ plugin: 'watchdog', key: 'band' }).catch(() => undefined);
86 restoreBand(stored?.value ?? EMPTY_BAND);
87 memory.written = JSON.stringify(stored?.value ?? EMPTY_BAND);
88 }
89 return result;
90};
91
92// §12.5: the watchdogs of the roster in a problem state, as `$.state` key `health` keeps them.
93const troubles = (health: Health | undefined): Trouble[] =>
94 currentRoster().flatMap(({ name, slug }) => {
95 const problem = health?.watchdogs[slug]?.problem ?? null;
96 return problem === null ? [] : [{ name, problem }];
97 });
98
99// §12.5: the failure line while on; the clock is read only for a halt's next try.
100const troubleText = async ($: EngineInterface, health: Health | undefined, columns: number) => {
101 const found = troubles(health);
102 const isHalted = found.some(({ problem }) => problem.state === 'halted');
103 const now = isHalted ? await $.clock.now() : 0;
104 return troubleLine(found, { now, columns, isAlone: currentRoster().length === 1 });
105};
106
107// §13.1: the `$.state` keys the band draws. Reading them subscribes the band, so each write draws it again.
108const readKeys = async ($: EngineInterface) => {
109 const on = await $.state.get({ plugin: 'watchdog', key: 'on' }).catch(() => undefined);
110 const band = await $.state.get({ plugin: 'watchdog', key: 'band' }).catch(() => undefined);
111 const health = await $.state.get({ plugin: 'watchdog', key: 'health' }).catch(() => undefined);
112 const isOn = on?.value?.isOn;
113 return { isOn, band: band?.value ?? EMPTY_BAND, health: isOn === true ? health?.value : undefined };
114};
115
116// §13.1: the band reads its keys first, also in a session it does not draw, so that a write that comes before
117// the session start of a reload draws it. It yields to a survey, and draws nothing in a headless session (§10.6).
118const onBand: BandHook = async ($, e, next) => {
119 if (e.props.hasSurvey) {
120 return next(e);
121 }
122 const base: RenderElement = await next(e);
123 const { isOn, band, health } = await readKeys($);
124 if (!isInteractiveSession() || currentMode() === 'unsupported') {
125 return base;
126 }
127 const columns = e.props.bodyColumns;
128 const trouble = await troubleText($, health, columns);
129 const el = $.ui.resolve(e);
130 // §13.1: a collapsed card names its watchdog only when 2 or more watchdogs are enabled.
131 const watchdogs = currentRoster().filter((watchdog) => watchdog.isEnabled).length;
132 const tree = bandTree(el, { isOn, band, trouble, columns, watchdogs, onToggle: toggleCard });
133 if (tree === undefined) {
134 return base;
135 }
136 // §13.1: no row above the divider. The engine draws its `[-]` on the band's first row, so the divider holds it in
137 // each state.
138 return el.Box({ key: 'watchdog-band', flexDirection: 'column', children: [base, tree] });
139};
140
141// Install first, so that these hooks sit above the note, delivery and command hooks that change the cards.
142// The matchers only tell these `on()` from the other areas'.
143export const installBand = (
144 on: OnEvents<
145 | 'tool.call'
146 | 'turn.start'
147 | 'turn.complete'
148 | 'prompt.submit'
149 | 'command.run'
150 | 'session.start'
151 | 'ui.render'
152 | 'ui.press'
153 >
154): void => {
155 watchHeldNotes(changeCard);
156 on('tool.call', { tool: /./u }, afterTool);
157 on('turn.start', { turnId: /^/u }, afterTurnStart);
158 on('turn.complete', { answer: /^/u }, afterTurnComplete);
159 on('prompt.submit', { origin: { kind: /./u } }, onPrompt);
160 on('command.run', { command: /^watchdog$/u }, afterCommand);
161 on('session.start', { cwd: /./u }, onSessionStart);
162 on('ui.render', { component: 'AbovePrompt' }, onBand);
163 on('ui.press', { plugin: 'watchdog', element: /^watchdog-expand-/u }, afterPress);
164};
165hooks/command/install.ts 269 lines1import { preflightProblem, preflightReject, preflightRequest } from '../agents/preflight';
2import { setRegisteredSpec } from '../agents/registered';
3import { setRoster } from '../agents/roster';
4import { agentSpec } from '../agents/spec';
5import { systemPrompt } from '../agents/system-prompt';
6import { COMMAND_LOG_DELAY_MS } from '../constants';
7import { currentNudgeClock, nudgeValue, setNudgeClock } from '../delivery/nudge';
8import { addDumpLines } from '../dump/sections';
9import { errorText } from '../errors';
10import { EMPTY_FEED, currentFeed, setFeed, startFeed } from '../feed/feed';
11import { freezeGuidance } from '../guidance/memory';
12import { currentMode, setMode } from '../lifecycle/mode';
13import {
14 DESKTOP_DROP_WARNING,
15 addOnWarning,
16 currentOnSource,
17 hasPrompted,
18 isEnvOn,
19 isEnvOnFlag,
20 isInteractiveSession,
21 isOnByDefault,
22 onStoreKey,
23 onWarnings,
24 pickOnFlag,
25 setInteractiveSession,
26 setOnByDefault,
27 setOnSource,
28} from '../lifecycle/on-order';
29import { clearHeldNotes } from '../note/notes';
30import { WATCHDOG_TOOLS } from '../note/tool';
31import { resetCadences } from '../review/cadence';
32import { slotsAfterOn } from '../review/slots';
33import { buildRoster } from '../roster/merge';
34import { sessionEffort } from '../roster/model';
35import { searchPaths } from '../roster/paths';
36import { parseSubcommand } from './args';
37import { UNSUPPORTED_REPLY, USAGE_REPLY } from './spec';
38import { addStatusLines, statusHeadline, statusText } from './status';
39import type { Roster, WatchedFile, Watchdog } from '../agents/roster';
40import type { OnFlag, OnSource } from '../lifecycle/on-order';
41import type { OnEvents } from '../on';
42import type { LoadedFile } from '../roster/merge';
43import type { SearchPath, Where } from '../roster/paths';
44import type { EngineInterface, Hook, PluginOptions } from 'claude-code';
45
46// §5.2 step 4, §14.1: `$.state` keeps the on flag and the feed; a refused write loses only the carry-over.
47const saveOnState = async ($: EngineInterface): Promise<void> => {
48 const source = currentOnSource();
49 const flag: OnFlag = currentMode() === 'on' && source !== undefined ? { isOn: true, source } : { isOn: false };
50 await $.state.set({ plugin: 'watchdog', key: 'on' }, flag).catch(() => undefined);
51 await $.state.set({ plugin: 'watchdog', key: 'feed' }, currentFeed()).catch(() => undefined);
52};
53
54// §5.2 step 1, §6.1, §8.3, §10.8: the mod's tools, then each runnable watchdog's agent type; each slug → its blocker.
55const registerAll = async (
56 $: EngineInterface,
57 watchdogs: readonly Watchdog[]
58): Promise<Map<string, string | undefined>> => {
59 const registered = Promise.all(WATCHDOG_TOOLS.map(async (tool) => $.tool.register(tool)));
60 const toolProblem = await registered.then(() => undefined, errorText);
61 if (toolProblem !== undefined) {
62 return new Map(watchdogs.map((watchdog) => [watchdog.slug, toolProblem]));
63 }
64 const base = await $.fs.read(`${$.plugin.root}/prompts/system.md`);
65 const problems = await Promise.all(
66 watchdogs.map(async (watchdog) => {
67 const spec = agentSpec(watchdog, systemPrompt(base, watchdog), sessionEffort());
68 return $.agent.register(spec).then(() => {
69 setRegisteredSpec(spec);
70 return undefined;
71 }, errorText);
72 })
73 );
74 return new Map(watchdogs.map((watchdog, index) => [watchdog.slug, problems[index]]));
75};
76
77// §5.2 step 2: one 1-token call for each distinct model. Maps each model to its `no_model` reason, or undefined.
78const preflightAll = async (
79 $: EngineInterface,
80 models: readonly string[]
81): Promise<Map<string, string | undefined>> => {
82 const distinct = [...new Set(models)];
83 const problems = await Promise.all(
84 distinct.map(async (model) =>
85 $.model.complete(preflightRequest(model)).then(preflightProblem, preflightReject(model))
86 )
87 );
88 return new Map(distinct.map((model, index) => [model, problems[index]]));
89};
90
91// §4.5: a searched path and its time at the read; a path that does not stat is not there.
92const readSearchPath = async ($: EngineInterface, search: SearchPath): Promise<LoadedFile & WatchedFile> => {
93 const mtimeMs = await $.fs.stat(search.path).then(
94 (stat) => stat.mtimeMs,
95 () => null
96 );
97 if (mtimeMs === null) {
98 return { ...search, mtimeMs };
99 }
100 const content = await $.fs.read(search.path).then(
101 (text) => ({ text }),
102 (error: unknown) => ({ error: errorText(error) })
103 );
104 return { ...search, mtimeMs, content };
105};
106
107// §4.3, §4.5: the user file and the project files of `WATCHDOG.json` and `WATCHDOG.md`, read at `/watchdog on`;
108// the roster and the guidance (§4.4) stay frozen until the next `/watchdog on`.
109const loadRoster = async ($: EngineInterface): Promise<{ roster: Roster; files: WatchedFile[] }> => {
110 const where: Where = {
111 configDir: await $.env.get('CLAUDE_CONFIG_DIR'),
112 home: await $.env.get('HOME'),
113 gitRoot: (await $.session.repo())?.root ?? null,
114 cwd: await $.session.cwd(),
115 root: await $.session.root(),
116 };
117 const read = async (name: string): Promise<(LoadedFile & WatchedFile)[]> =>
118 Promise.all(searchPaths(where, name).map(async (search) => readSearchPath($, search)));
119 const [files, guides] = await Promise.all([read('WATCHDOG.json'), read('WATCHDOG.md')]);
120 freezeGuidance(guides);
121 const watched = [...files, ...guides].map(({ path, mtimeMs }) => ({ path, mtimeMs }));
122 return { roster: buildRoster(files, where), files: watched };
123};
124
125// §4.6, §13.2: one row with the warning count; it waits, so that from a `command.run` hook it lands below the
126// command echo.
127const logWarnings = ($: EngineInterface, count: number): void => {
128 if (count > 0) {
129 const text = `${count} WATCHDOG.json ${count === 1 ? 'warning' : 'warnings'}; see /watchdog status`;
130 $.clock.after(COMMAND_LOG_DELAY_MS, () => {
131 $.ui.log(text);
132 });
133 }
134};
135
136// §5.2: read the roster, register, preflight, move the feed cursors to the end, then set the on flag and the
137// on source. The roster is set before the register, whose system prompt reads it (§8.1).
138const turnOn = async ($: EngineInterface, source: OnSource): Promise<void> => {
139 const { roster, files } = await loadRoster($);
140 setRoster(roster, files);
141 const runnable = roster.watchdogs.filter((watchdog) => watchdog.isEnabled && watchdog.noModel === null);
142 const blocked = await registerAll($, runnable);
143 const noModel = await preflightAll(
144 $,
145 runnable.filter((watchdog) => blocked.get(watchdog.slug) === undefined).map((watchdog) => watchdog.model)
146 );
147 const reviewers = slotsAfterOn(roster.watchdogs, blocked, noModel);
148 resetCadences();
149 setFeed(startFeed(reviewers));
150 setMode('on');
151 setOnSource(source);
152 await saveOnState($);
153 logWarnings($, roster.warnings.length);
154};
155
156// §5.2: stop feed recording; clear the backlog, the held notes and a waiting nudge, in `$.state` too.
157const turnOff = async ($: EngineInterface): Promise<void> => {
158 setMode('off');
159 setOnSource(undefined);
160 setFeed(EMPTY_FEED);
161 clearHeldNotes();
162 setNudgeClock({ ...currentNudgeClock(), dueAt: null });
163 await saveOnState($);
164 await $.state.set({ plugin: 'watchdog', key: 'nudge' }, nudgeValue([])).catch(() => undefined);
165};
166
167// §5.4: the person's toggle follows the session id into a new process (`claude -r`), with `lastUsed` (§14.2).
168const storeOnFlag = async ($: EngineInterface, flag: OnFlag): Promise<void> => {
169 const sessionId = await $.session.id();
170 await $.store.set(onStoreKey(sessionId), { ...flag, lastUsed: await $.clock.now() });
171};
172
173const toggle = async ($: EngineInterface, isOn: boolean): Promise<void> => {
174 await (isOn ? turnOn($, '/watchdog on') : turnOff($));
175 await storeOnFlag($, isOn ? { isOn: true, source: '/watchdog on' } : { isOn: false }).catch(() => undefined);
176};
177
178// §5.1: switch to the flag that the order picked. An off session that stays off writes nothing, so a later
179// `onByDefault` still applies after a reload.
180const applyOnFlag = async ($: EngineInterface, flag: OnFlag): Promise<void> => {
181 if (flag.isOn && currentMode() === 'on') {
182 setOnSource(flag.source);
183 await saveOnState($);
184 return;
185 }
186 if (flag.isOn || currentMode() === 'on') {
187 await (flag.isOn ? turnOn($, flag.source) : turnOff($));
188 }
189};
190
191const readOnState = async ($: EngineInterface): Promise<unknown> =>
192 (await $.state.get({ plugin: 'watchdog', key: 'on' }).catch(() => undefined))?.value;
193
194const readStoredFlag = async ($: EngineInterface): Promise<unknown> => {
195 const sessionId = await $.session.id();
196 return $.store.get(onStoreKey(sessionId));
197};
198
199// §5.1: the `$.state` flag (undefined when dropped), then for an interactive session the stored flag and
200// `onByDefault`. A failed turn-on writes one log row.
201const applyOrder = async ($: EngineInterface, state: unknown, isInteractive: boolean): Promise<void> => {
202 const stored = isInteractive ? await readStoredFlag($).catch(() => undefined) : undefined;
203 const flag = pickOnFlag({ state, stored, onByDefault: isOnByDefault(), isInteractive, isEnvOn: isEnvOn() });
204 const problem = flag === undefined ? undefined : await applyOnFlag($, flag).then(() => undefined, errorText);
205 if (problem !== undefined) {
206 $.ui.log(`watchdog on failed: ${problem}`);
207 }
208};
209
210// §5.1: after the version gate beneath, set the on state by the order; §14.6: a desktop reload is interactive.
211const onSessionStart: Hook<'session.start'> = async ($, e, next) => {
212 const result = await next(e);
213 setInteractiveSession(e.isInteractive);
214 if (currentMode() !== 'unsupported') {
215 await applyOrder($, await readOnState($), isInteractiveSession());
216 }
217 return result;
218};
219
220// §5.3: a Desktop attach before the first prompt makes the session interactive. It drops an on state from
221// `CLAUDE_WATCHDOG` with one dump warning, then applies the order.
222const onDesktopAttach: Hook<'session.attach'> = async ($, e, next) => {
223 const result = await next(e);
224 if (hasPrompted() || currentMode() === 'unsupported') {
225 return result;
226 }
227 setInteractiveSession(true);
228 const state = await readOnState($);
229 const isDropped = isEnvOnFlag(state);
230 if (isDropped) {
231 addOnWarning(DESKTOP_DROP_WARNING);
232 }
233 await applyOrder($, isDropped ? undefined : state, true);
234 return result;
235};
236// §5.2, §13.3: on, off and status; `dump` goes to the dump hook beneath.
237const onWatchdogCommand: Hook<'command.run'> = async ($, e, next) => {
238 const subcommand = parseSubcommand(e.args);
239 if (subcommand === 'status') {
240 return { text: statusText(statusHeadline()) };
241 }
242 if (subcommand === 'unknown') {
243 return { text: USAGE_REPLY };
244 }
245 if ((subcommand === 'on' || subcommand === 'off') && currentMode() === 'unsupported') {
246 return { text: UNSUPPORTED_REPLY };
247 }
248 if (subcommand === 'on' || subcommand === 'off') {
249 const problem = await toggle($, subcommand === 'on').then(() => undefined, errorText);
250 return { text: problem === undefined ? statusText(statusHeadline()) : `watchdog ${subcommand} failed: ${problem}` };
251 }
252 return next(e);
253};
254
255// §5.4, §13.3, §13.4: the on source in the status while on, and always in the dump with the warnings.
256const onSourceLine = (): string => `on source: ${currentOnSource() ?? 'none (off)'}`;
257
258export const installCommand = (
259 on: OnEvents<'command.run' | 'session.start' | 'session.attach'>,
260 options: PluginOptions
261): void => {
262 setOnByDefault(options);
263 on('command.run', { command: 'watchdog' }, onWatchdogCommand);
264 on('session.start', { cwd: /^/u }, onSessionStart);
265 on('session.attach', { surface: 'desktop' }, onDesktopAttach);
266 addStatusLines(() => (currentMode() === 'on' ? [onSourceLine()] : []));
267 addDumpLines(() => [onSourceLine(), ...onWarnings().map((warning) => `warning: ${warning}`)]);
268};
269hooks/delivery/install.ts 260 lines1import { ownContext } from '../agents/ids';
2import { watchdogBySlug } from '../agents/roster';
3import { isOwnToolCall } from '../agents/self-review';
4import { addStatusHead, addStatusLines } from '../command/status';
5import { NUDGE_WAIT_MS } from '../constants';
6import { errorText } from '../errors';
7import { currentMode } from '../lifecycle/mode';
8import { addLogRecord, currentLog, errorRecord } from '../log/log';
9import { addDeliveryRoute, heldNotes, holdNote, rerouteNotes, takeNotes } from '../note/notes';
10import { isPersonPrompt } from '../person';
11import { wrapHeldNotes } from './held';
12import {
13 budgetOf,
14 currentNudgeClock,
15 currentRouting,
16 endMainTurn,
17 giveBackNudge,
18 immuneTurnsWarning,
19 isNudgePending,
20 lateRouteNow,
21 nudgeNotes,
22 nudgeStatus,
23 nudgeValue,
24 restoreNudge,
25 routeNow,
26 setImmuneTurns,
27 setNudgeClock,
28 spendNudge,
29 startMainTurn,
30} from './nudge';
31import { endReviewWaits, takeNudgeNotes } from './review-wait';
32import { countTurn, restoreTurn } from './turns';
33import type { DeliveryState, HeldNote } from '../note/notes';
34import type { OnEvents } from '../on';
35import type { EngineInterface, Hook, MatchedHook, PluginOptions, Timer } from 'claude-code';
36
37type SteerHook = MatchedHook<'tool.call', { tool: RegExp }>;
38
39// The shipped `prompts/boundary-guidance.md` (§10.7), read once; the 2 s wait of the nudge that waits
40// (§10.3); whether this module instance read its `$.state` back (§14.6).
41const memory: { guidance: string | undefined; wait: Timer | undefined; isLoaded: boolean } = {
42 guidance: undefined,
43 wait: undefined,
44 isLoaded: false,
45};
46
47const guidance = async ($: EngineInterface): Promise<string> => {
48 memory.guidance ??= await $.fs.read(`${$.plugin.root}/prompts/boundary-guidance.md`);
49 return memory.guidance;
50};
51
52// §14.1: `$.state` keeps the budgets, the cooldown and the nudge that waits with its notes; a refused write
53// loses only what a reload would carry over.
54const saveNudge = async ($: EngineInterface): Promise<void> => {
55 await $.state.set({ plugin: 'watchdog', key: 'nudge' }, nudgeValue(nudgeNotes(heldNotes()))).catch(() => undefined);
56};
57
58type Undelivered = {
59 readonly notes: readonly HeldNote[];
60 readonly route: (note: HeldNote) => DeliveryState;
61 readonly error: string;
62};
63
64// §10.1, §10.3: notes that a delivery could not send wait again, each in the state `route` gives it; the
65// review log gets one error.
66const keepUndelivered = async ($: EngineInterface, undelivered: Undelivered): Promise<void> => {
67 const { notes, route, error } = undelivered;
68 for (const note of notes) {
69 holdNote({ ...note, delivery: route(note) });
70 }
71 const names = notes.map((note) => watchdogBySlug(note.watchdog)?.name ?? note.watchdog);
72 const watchdog = [...new Set(names)].join(', ');
73 addLogRecord(errorRecord({ watchdog, time: await $.clock.now(), error }));
74 await $.state.set({ plugin: 'watchdog', key: 'log' }, currentLog()).catch(() => undefined);
75};
76
77// §10.1: one user row with the wrapped notes. Returns the reason of a reject or a deny, or undefined.
78const appendNotes = async ($: EngineInterface, notes: readonly HeldNote[]): Promise<string | undefined> => {
79 try {
80 const text = wrapHeldNotes(await guidance($), notes);
81 const appended = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } });
82 return 'deny' in appended ? appended.deny : undefined;
83 } catch (error) {
84 return errorText(error);
85 }
86};
87
88// §10.1: a failed append keeps the notes undelivered: they take the late-note route (§10.3).
89const steer = async ($: EngineInterface): Promise<void> => {
90 const notes = takeNotes(['steered'], 'steered');
91 if (notes.length === 0) {
92 return;
93 }
94 const problem = await appendNotes($, notes);
95 if (problem !== undefined) {
96 await keepUndelivered($, { notes, route: lateRouteNow, error: `steer append failed: ${problem}` });
97 }
98};
99
100// §10.1: the ready steers go after the next main-loop tool result, once `next(e)` resolved. The mod's own
101// synthetic calls (§7.3) and every subagent call steer nothing.
102const onToolCall: SteerHook = async ($, e, next) => {
103 const result = await next(e);
104 if (e.agentId === undefined && !isOwnToolCall(e, ownContext(), next.origin.plugin)) {
105 await steer($);
106 }
107 return result;
108};
109
110// §10.3: one awaited `$.prompt.submit` with the wrapped notes. Returns the reason of a reject or a drop.
111const submitNudge = async ($: EngineInterface, notes: readonly HeldNote[]): Promise<string | undefined> => {
112 try {
113 const submitted = await $.prompt.submit({ text: wrapHeldNotes(await guidance($), notes) });
114 return submitted.drop;
115 } catch (error) {
116 return errorText(error);
117 }
118};
119
120// §10.3, §10.4: the callback of the 2 s wait sends one nudge with every late note that waits, while no turn
121// runs (a nudge queued behind a turn would survive Esc); the notes go out as `nudged`, and an outdated blocker may
122// wait for a review first (§10.8). A refused nudge gives its budget back, and its notes wait as an aside.
123const sendNudge = async ($: EngineInterface): Promise<void> => {
124 memory.wait = undefined;
125 setNudgeClock({ ...currentNudgeClock(), dueAt: null });
126 const notes = currentRouting().isTurnRunning ? [] : takeNudgeNotes();
127 const budget = budgetOf(notes);
128 if (notes.length > 0) {
129 spendNudge(budget);
130 }
131 await saveNudge($);
132 const problem = notes.length === 0 ? undefined : await submitNudge($, notes);
133 if (problem !== undefined) {
134 giveBackNudge(budget);
135 await keepUndelivered($, { notes, route: () => 'held', error: `nudge failed: ${problem}` });
136 await saveNudge($);
137 }
138};
139
140const startWait = ($: EngineInterface, ms: number): void => {
141 memory.wait = $.clock.after(ms, () => {
142 sendNudge($).catch(() => undefined);
143 });
144};
145
146// §10.3: late notes that wait while the session is idle start one 2 s wait; the notes ready within it share
147// the nudge. Only a `turn.complete` (or the load) starts it: a submit from a timer that a `tool.call` hook
148// set is refused, so the note hook never does.
149const canArm = (): boolean =>
150 !currentRouting().isTurnRunning && memory.wait === undefined && heldNotes().some(isNudgePending);
151
152// The clock is read before the wait starts, so a wait that runs out at once finds `dueAt` set and its nudge's
153// budget is not written over.
154const armNudge = async ($: EngineInterface): Promise<void> => {
155 if (!canArm()) {
156 return;
157 }
158 const dueAt = (await $.clock.now()) + NUDGE_WAIT_MS;
159 if (!canArm()) {
160 return;
161 }
162 setNudgeClock({ ...currentNudgeClock(), dueAt });
163 startWait($, NUDGE_WAIT_MS);
164 await saveNudge($);
165};
166
167// §10.7: the main-loop counter adds 1 at each `turn.start`; `$.state` key `turns` keeps a copy (§14.1).
168// §10.3 step 2: a turn that starts ends the 2 s wait, and its notes wait for a tool result of that turn.
169const onTurnStart: Hook<'turn.start'> = async ($, e, next) => {
170 const turn = countTurn();
171 startMainTurn(e.text);
172 memory.wait?.cancel();
173 memory.wait = undefined;
174 rerouteNotes((note) => (isNudgePending(note) ? 'steered' : note.delivery));
175 await $.state.set({ plugin: 'watchdog', key: 'turns' }, turn).catch(() => undefined);
176 await saveNudge($);
177 return next(e);
178};
179
180// §10.3: at the main-loop end, a steer with no tool result is a late note; after Esc (`isAborted`) it waits
181// as an aside. §10.8: a review's end ends the wait of the outdated blockers it saw. Any `turn.complete` while the
182// session is idle (a review's own too) starts the 2 s wait.
183const onTurnComplete: Hook<'turn.complete'> = async ($, e, next) => {
184 const result = await next(e);
185 if (e.agentId === undefined) {
186 endMainTurn(e.isAborted);
187 rerouteNotes((note) => (note.delivery === 'steered' || isNudgePending(note) ? lateRouteNow(note) : note.delivery));
188 }
189 endReviewWaits(e.agentId);
190 await armNudge($);
191 return result;
192};
193
194// §10.2, §13.1: the nits and the held notes in one wrapper; undefined when none waits. If the guidance
195// cannot be read, they wait for the next person prompt.
196const asideText = async ($: EngineInterface): Promise<string | undefined> => {
197 const isWaiting = heldNotes().some((note) => note.delivery === 'aside on next prompt' || note.delivery === 'held');
198 const head = isWaiting ? await guidance($).catch(() => undefined) : undefined;
199 return head === undefined
200 ? undefined
201 : wrapHeldNotes(head, takeNotes(['aside on next prompt', 'held'], 'aside on next prompt'));
202};
203
204// §10.2, §10.4: a person prompt resets both nudge budgets and carries the aside in its `context`, added on
205// the way down: a `context` added to the result after `next` is dropped (d.ts 8803-8805). Any other origin,
206// a task notification too (§10.5), passes as it came.
207const onPromptSubmit: Hook<'prompt.submit'> = async ($, e, next) => {
208 if (!isPersonPrompt(e.origin)) {
209 return next(e);
210 }
211 setNudgeClock({ ...currentNudgeClock(), nudges: 0, blockerNudges: 0 });
212 await saveNudge($);
213 const aside = await asideText($);
214 return next(aside === undefined ? e : { ...e, context: [...(e.context ?? []), aside] });
215};
216
217// §10.3, §14.6: a reload cancels the old instance's timers. At load the counter, the budgets, the cooldown
218// and the nudge that waits come back from `$.state`, and the wait starts again with the time left.
219const restoreDelivery = async ($: EngineInterface): Promise<void> => {
220 memory.isLoaded = true;
221 const turns = await $.state.get({ plugin: 'watchdog', key: 'turns' }).catch(() => undefined);
222 const nudge = await $.state.get({ plugin: 'watchdog', key: 'nudge' }).catch(() => undefined);
223 if (turns?.value !== undefined) {
224 restoreTurn(turns.value);
225 }
226 for (const note of restoreNudge(nudge?.value)) {
227 holdNote({ ...note, delivery: 'nudge pending' });
228 }
229 const { dueAt } = currentNudgeClock();
230 if (dueAt !== null) {
231 startWait($, Math.max(0, dueAt - (await $.clock.now())));
232 }
233};
234
235const onSessionStart: Hook<'session.start'> = async ($, e, next) => {
236 const result = await next(e);
237 if (!memory.isLoaded) {
238 await restoreDelivery($);
239 }
240 return result;
241};
242
243export const installDelivery = (
244 on: OnEvents<'turn.start' | 'turn.complete' | 'tool.call' | 'prompt.submit' | 'session.start'>,
245 options: PluginOptions
246): void => {
247 setImmuneTurns(options.immuneTurns);
248 on('turn.start', onTurnStart);
249 on('turn.complete', { reason: /^/u }, onTurnComplete);
250 on('tool.call', { tool: /^/u }, onToolCall);
251 on('prompt.submit', onPromptSubmit);
252 on('session.start', { isInteractive: [true, false] }, onSessionStart);
253 addDeliveryRoute(routeNow);
254 addStatusHead(() => (currentMode() === 'on' ? nudgeStatus(currentRouting()) : []));
255 addStatusLines(() => {
256 const warning = immuneTurnsWarning();
257 return warning === undefined ? [] : [`warning: ${warning}`];
258 });
259};
260hooks/dump/install.ts 115 lines1import { watchdogBySlug } from '../agents/roster';
2import { parseSubcommand } from '../command/args';
3import { COMMAND_LOG_DELAY_MS } from '../constants';
4import { errorText } from '../errors';
5import { currentMode } from '../lifecycle/mode';
6import { isInteractiveSession, onWarnings } from '../lifecycle/on-order';
7import { addLogRecord, currentLog, recentPrompts, unreviewedRecord } from '../log/log';
8import { heldNotes, logRow } from '../note/notes';
9import { waitingUpdates } from '../review/backlog';
10import { watchedFeeds } from '../review/backlogs';
11import { slotOf } from '../review/slots';
12import { configDir, dumpPath, dumpText } from './dump';
13import { addDumpLines, dumpLines } from './sections';
14import type { OnEvents } from '../on';
15import type { EngineInterface, Hook } from 'claude-code';
16
17// §13.2, §13.4: on the terminal the dump text goes to the clipboard too; the row of the outcome waits, so
18// that it lands below the command echo. The desktop has no clipboard path, so it gets the file only.
19const copyDump = async ($: EngineInterface, text: string): Promise<void> => {
20 const surfaces = await $.session.surfaces();
21 if (!surfaces.includes('terminal')) {
22 return;
23 }
24 const row = await $.ui.copy({ text, surface: 'terminal' }).then(
25 (copied) => (copied.isCopied ? 'dump copied to the clipboard' : `dump not copied: ${copied.reason}`),
26 (error: unknown) => `dump not copied: ${errorText(error)}`
27 );
28 $.clock.after(COMMAND_LOG_DELAY_MS, () => {
29 $.ui.log(row);
30 });
31};
32
33// §13.4: the review log, the lines of the other areas and, for `dump raw`, the last prompts, in one file
34// under `<config>/watchdog/dumps/`. Returns its path and text; undefined when no `<config>` is known.
35const writeDumpFile = async (
36 $: EngineInterface,
37 sessionId: string,
38 isRaw: boolean
39): Promise<{ path: string; text: string } | undefined> => {
40 const config = configDir(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'));
41 if (config === undefined) {
42 return undefined;
43 }
44 const time = await $.clock.now();
45 const text = dumpText({
46 sessionId,
47 time,
48 lines: dumpLines(),
49 records: currentLog(),
50 ...(isRaw ? { prompts: recentPrompts() } : {}),
51 });
52 const path = dumpPath(config, sessionId, time);
53 await $.fs.write(path, text);
54 return { path, text };
55};
56
57// §13.4: `/watchdog dump`: the file, and on the terminal the clipboard. Returns the reply.
58const writeDump = async ($: EngineInterface, isRaw: boolean): Promise<string> => {
59 const dump = await writeDumpFile($, await $.session.id(), isRaw);
60 if (dump === undefined) {
61 return 'watchdog dump failed: neither CLAUDE_CONFIG_DIR nor HOME is set';
62 }
63 await copyDump($, dump.text);
64 return `watchdog dump: ${dump.path}`;
65};
66
67// §13.4: `/watchdog dump` and `/watchdog dump raw`; the command hook passes them here.
68const onDumpCommand: Hook<'command.run'> = async ($, e, next) => {
69 const subcommand = parseSubcommand(e.args);
70 if (subcommand !== 'dump' && subcommand !== 'dump raw') {
71 return next(e);
72 }
73 const reply = await writeDump($, subcommand === 'dump raw').catch(
74 (error: unknown) => `watchdog dump failed: ${errorText(error)}`
75 );
76 return { text: reply };
77};
78
79// §7.5, §11.2: one `unreviewed: N updates` record for each backlog that no review took, the primary agent's and
80// each watched subagent's: the updates after the cursor, or after the batch of the review that runs on it.
81const recordUnreviewed = async ($: EngineInterface): Promise<void> => {
82 const time = await $.clock.now();
83 const records = watchedFeeds().flatMap(({ subagent, feed }) =>
84 Object.entries(feed.cursors).flatMap(([slug, cursor]) => {
85 const slot = slotOf(slug);
86 const isTaken = slot.state === 'reviewing' && slot.subagent === subagent?.agentId;
87 const updates = waitingUpdates(feed, isTaken ? slot.batchEnd : cursor);
88 const watchdog = watchdogBySlug(slug)?.name ?? slug;
89 return updates === 0 ? [] : [unreviewedRecord({ watchdog, time, updates })];
90 })
91 );
92 for (const record of records) {
93 addLogRecord(record);
94 }
95};
96
97// §10.6: at the end of a headless session, the dump file holds the notes that still wait, the `unreviewed`
98// records and the warnings. A session that never turned on and has no warning leaves no file.
99const onSessionEnd: Hook<'session.end'> = async ($, e, next) => {
100 if (!isInteractiveSession() && (currentMode() === 'on' || onWarnings().length > 0)) {
101 await recordUnreviewed($).catch(() => undefined);
102 await writeDumpFile($, e.sessionId, false).catch(() => undefined);
103 }
104 return next(e);
105};
106
107export const installDump = (on: OnEvents<'command.run' | 'session.end'>): void => {
108 on('command.run', { command: 'watchdog' }, onDumpCommand);
109 on('session.end', onSessionEnd);
110 // §10.6, §13.4: the notes that still wait, as their log row shows them.
111 addDumpLines(() =>
112 heldNotes().map((note) => `waiting: ${logRow(note, watchdogBySlug(note.watchdog)?.name ?? note.watchdog)}`)
113 );
114};
115hooks/failure/install.ts 99 lines1import { watchdogOf } from '../agents/ids';
2import { currentRoster } from '../agents/roster';
3import { parseSubcommand } from '../command/args';
4import { COMMAND_LOG_DELAY_MS } from '../constants';
5import { changedHealth, rememberErrorText, resetFailures, restoreHealth, stateRows } from './state';
6import type { OnEvents } from '../on';
7import type { EngineInterface, Hook } from 'claude-code';
8
9// §12.5: one `$.ui.log` row for each change of a problem state; from a `command.run` hook it waits (§13.2).
10// §12.3, §14.1: `$.state` keeps the failure state, written when it changed; a refused write loses only what a
11// reload would carry over.
12const report = async ($: EngineInterface, isCommand = false): Promise<void> => {
13 stateRows(currentRoster()).forEach((row) => {
14 if (isCommand) {
15 $.clock.after(COMMAND_LOG_DELAY_MS, () => {
16 $.ui.log(row);
17 });
18 return;
19 }
20 $.ui.log(row);
21 });
22 const health = changedHealth(currentRoster());
23 if (health !== undefined) {
24 await $.state.set({ plugin: 'watchdog', key: 'health' }, health).catch(() => undefined);
25 }
26};
27
28// §12.1: the text of a review's error is in its agent's synthetic row; the hook relays the row unchanged.
29const onSyntheticRow: Hook<'session.append'> = async (_$, e, next) => {
30 if (watchdogOf(e.agentId) !== undefined && e.agentId !== undefined) {
31 const text = e.message.content.map((block) =>
32 'text' in block && typeof block.text === 'string' ? block.text : ''
33 );
34 rememberErrorText(e.agentId, text.join('\n'));
35 }
36 return next(e);
37};
38
39// The review area beneath ends reviews and spawns them at boundaries and at person prompts; each row and
40// each state write follows.
41const onStep: Hook<'turn.step'> = async function* ($, e, next) {
42 const response = yield* next(e);
43 if (e.agentId === undefined) {
44 await report($);
45 }
46 return response;
47};
48
49const onComplete: Hook<'turn.complete'> = async ($, e, next) => {
50 const result = await next(e);
51 await report($);
52 return result;
53};
54
55const onPrompt: Hook<'prompt.submit'> = async ($, e, next) => {
56 const result = await next(e);
57 await report($);
58 return result;
59};
60
61// §5.2: `/watchdog on` beneath sets the states again and tries each watchdog at once, so the failure counts
62// of its roster go (§12.3 item 2).
63const onCommand: Hook<'command.run'> = async ($, e, next) => {
64 const result = await next(e);
65 if (parseSubcommand(e.args) === 'on') {
66 resetFailures(currentRoster());
67 await report($, true);
68 }
69 return result;
70};
71
72// §12.3: at module load, after the on order beneath turned the session on again, the stored failure state
73// comes back: the counts, the problems and the halt with its next-try time, so its wait goes on.
74const onSessionStart: Hook<'session.start'> = async ($, e, next) => {
75 const result = await next(e);
76 const stored = await $.state.get({ plugin: 'watchdog', key: 'health' }).then(
77 (read) => read.value,
78 () => undefined
79 );
80 if (stored !== undefined) {
81 restoreHealth(stored, currentRoster());
82 }
83 await report($);
84 return result;
85};
86
87// Install before the command and review areas, so that these hooks sit above theirs. The matchers only tell
88// these `on()` from the other areas'.
89export const installFailure = (
90 on: OnEvents<'session.append' | 'turn.step' | 'turn.complete' | 'prompt.submit' | 'command.run' | 'session.start'>
91): void => {
92 on('session.append', { origin: { kind: 'model', model: '<synthetic>' } }, onSyntheticRow);
93 on('turn.step', { model: /^/u }, onStep);
94 on('turn.complete', { isAborted: [true, false] }, onComplete);
95 on('prompt.submit', { wait: [true, false] }, onPrompt);
96 on('command.run', { command: 'watchdog' }, onCommand);
97 on('session.start', { isInteractive: [true, false] }, onSessionStart);
98};
99hooks/feed/install.ts 52 lines1import { ownContext } from '../agents/ids';
2import { isOwnRow } from '../agents/self-review';
3import { currentMode } from '../lifecycle/mode';
4import { isPersonPrompt } from '../person';
5import { currentFeed, recordRow, setFeed } from './feed';
6import type { OnEvents } from '../on';
7import type { EngineInterface, Hook, PromptOrigin } from 'claude-code';
8
9// §10: a person prompt is known by its `prompt.submit` origin. The engine appends the prompt's row while the
10// submit runs, and in `-p` it stamps that row `unclassified` though the submit says `sdk` (live probe
11// l3-headless-prompt). So a `prompt` row that enters during a person submit takes the submit's origin.
12const memory: { submitting: PromptOrigin | undefined } = { submitting: undefined };
13
14// §7.1: `$.state` keeps the feed. The live copy is module memory, so a refused write (a value over
15// 4,194,304 characters of JSON) loses only what a reload would carry over.
16const saveFeed = async ($: EngineInterface): Promise<void> => {
17 await $.state.set({ plugin: 'watchdog', key: 'feed' }, currentFeed()).catch(() => undefined);
18};
19
20// §7.1: while on, each main-loop row enters the feed rendered (§7.6), except the watchdog's own rows (§7.3).
21// A row with an `agentId` never enters the primary agent's feed. The row is kept before `next(e)`, which
22// the hook relays.
23const onAppend: Hook<'session.append'> = async ($, e, next) => {
24 const isRecorded = currentMode() === 'on' && e.agentId === undefined && !isOwnRow(e, ownContext());
25 if (isRecorded) {
26 const { submitting } = memory;
27 setFeed(
28 recordRow(currentFeed(), e.door === 'prompt' && submitting !== undefined ? { ...e, origin: submitting } : e)
29 );
30 }
31 const result = await next(e);
32 if (isRecorded) {
33 await saveFeed($);
34 }
35 return result;
36};
37
38const onPersonPrompt: Hook<'prompt.submit'> = async (_$, e, next) => {
39 if (!isPersonPrompt(e.origin)) {
40 return next(e);
41 }
42 memory.submitting = e.origin;
43 return next(e).finally(() => {
44 memory.submitting = undefined;
45 });
46};
47
48export const installFeed = (on: OnEvents<'session.append' | 'prompt.submit'>): void => {
49 on('session.append', onAppend);
50 on('prompt.submit', { origin: { kind: /./u } }, onPersonPrompt);
51};
52hooks/guidance/install.ts 97 lines1import { currentRosterConfig, watchdogBySlug } from '../agents/roster';
2import { agentType } from '../agents/spec';
3import { frozenGuidance, isRegistered, setFrozenReads, setRegistered } from './memory';
4import { fullPrompt, isContextType, soleRepoChild } from './prompt';
5import type { OnEvents } from '../on';
6import type { Frozen } from './memory';
7import type { ContextFile, Fragments } from './prompt';
8import type { EngineInterface, Hook } from 'claude-code';
9
10// §8.2: the shipped fragments, read at the register of `/watchdog on` (§8.1).
11const readFragments = async ($: EngineInterface): Promise<Fragments> => {
12 const read = async (name: string): Promise<string> => $.fs.read(`${$.plugin.root}/prompts/${name}`);
13 const [context, memory, activeRepo] = await Promise.all([
14 read('context-files.md'),
15 read('memory-context.md'),
16 read('active-repo-watchdog.md'),
17 ]);
18 return { context, memory, activeRepo };
19};
20
21// Build-session choice "Prompt fragments": outside git, the one direct child of the cwd that has a `.git`.
22const readRepoChild = async ($: EngineInterface): Promise<string | null> => {
23 if ((await $.session.repo()) !== null) {
24 return null;
25 }
26 const cwd = await $.session.cwd();
27 const entries = await $.fs.list(cwd).catch(() => []);
28 const children = await Promise.all(
29 entries
30 .filter((entry) => entry.kind === 'dir')
31 .map(async ({ name }) => ({ name, hasGit: await $.fs.exists(`${cwd}/${name}/.git`).catch(() => false) }))
32 );
33 return soleRepoChild(children);
34};
35
36// §4.5: what `/watchdog on` froze; its first register reads the fragments and the repo child.
37const readFrozen = async ($: EngineInterface): Promise<Required<Frozen>> => {
38 const frozen = frozenGuidance();
39 if (frozen.reads !== undefined) {
40 return { guidance: frozen.guidance, reads: frozen.reads };
41 }
42 const [fragments, repoChild] = await Promise.all([readFragments($), readRepoChild($)]);
43 setFrozenReads({ fragments, repoChild });
44 return { guidance: frozen.guidance, reads: { fragments, repoChild } };
45};
46
47// §8.2: the session's memory files of now, each read with `$.fs.read`; a file that does not read is left out.
48const readContext = async ($: EngineInterface): Promise<ContextFile[]> => {
49 const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined);
50 const files = (usage?.context.breakdown?.memoryFiles ?? []).filter((file) => isContextType(file.type));
51 const read = await Promise.all(
52 files.map(async ({ path, type }) =>
53 $.fs.read(path).then(
54 (content) => [{ path, type, content }],
55 () => []
56 )
57 )
58 );
59 return read.flat();
60};
61
62// §4.5, §8.1: each register of a watchdog type by the mod (at `/watchdog on` and before each spawn) gets the
63// full system prompt: the parts after the base the caller filled, the context of now among them. Only a spec
64// that differs from the last one registered reaches the engine; an unchanged spec is a no-op.
65const onRegister: Hook<'agent.register'> = async ($, e, next) => {
66 const watchdog = watchdogBySlug(e.name);
67 if (next.origin.plugin !== 'watchdog' || watchdog === undefined) {
68 return next(e);
69 }
70 const [frozen, contextFiles] = await Promise.all([readFrozen($), readContext($)]);
71 const spec = {
72 ...e,
73 prompt: fullPrompt({
74 base: e.prompt,
75 fragments: frozen.reads.fragments,
76 contextFiles,
77 tools: watchdog.tools,
78 guidance: frozen.guidance,
79 repoChild: frozen.reads.repoChild,
80 rosterInstructions: currentRosterConfig().instructions,
81 instructions: watchdog.instructions,
82 }),
83 };
84 if (isRegistered(spec)) {
85 return { value: { agent: agentType(e.name) } };
86 }
87 const result = await next(spec);
88 if (!('deny' in result)) {
89 setRegistered(spec);
90 }
91 return result;
92};
93
94export const installGuidance = (on: OnEvents<'agent.register'>): void => {
95 on('agent.register', onRegister);
96};
97hooks/lifecycle/install.ts 76 lines1import { COMMAND } from '../command/spec';
2import { errorText } from '../errors';
3import { WATCHDOG_TOOLS } from '../note/tool';
4import { envSwitch, settingsUnreadWarning } from './headless';
5import { setMode } from './mode';
6import { addOnWarning, notePrompt, setEnvOn } from './on-order';
7import { isSupportedVersion } from './version';
8import type { OnEvents } from '../on';
9import type { EnvSwitch } from './headless';
10import type { EngineInterface, Hook } from 'claude-code';
11
12// §8.3, §10.8: the `note` and `resolve` tools exist from the next prompt on, so they register before any spawn.
13// `/watchdog on` registers them again and blocks the watchdogs on a refusal, so a refusal here waits for that.
14const registerTools = async ($: EngineInterface): Promise<void> => {
15 await Promise.all(WATCHDOG_TOOLS.map(async (tool) => $.tool.register(tool).catch(() => undefined)));
16};
17
18// §5.1: the call throws for a name that another plugin has; one row tells the person.
19const registerCommand = async ($: EngineInterface): Promise<void> => {
20 try {
21 await $.command.register(COMMAND);
22 } catch (error) {
23 $.ui.log(`/watchdog is not registered: ${errorText(error)}`);
24 }
25};
26
27// §5.3: the project and local settings must not turn a headless run on (ADR 0001); a read that fails keeps
28// the session off too.
29const projectSwitch = async ($: EngineInterface, value: string): Promise<EnvSwitch> =>
30 Promise.all([$.settings.read({ source: 'project' }), $.settings.read({ source: 'local' })]).then(
31 ([project, local]) => envSwitch(value, { project, local }),
32 (error: unknown) => ({ isOn: false, warning: settingsUnreadWarning(errorText(error)) })
33 );
34
35// §5.3: every session reads `CLAUDE_WATCHDOG` and unsets it, so no Bash child and no nested `claude -p` gets
36// it. Only a headless session uses the value; the on order (`command/install.ts`) applies it.
37const takeEnvSwitch = async ($: EngineInterface, isUsed: boolean): Promise<void> => {
38 const value = await $.env.get('CLAUDE_WATCHDOG').catch(() => undefined);
39 if (value === undefined) {
40 return;
41 }
42 await $.env.set('CLAUDE_WATCHDOG', undefined).catch(() => undefined);
43 if (!isUsed || value === '') {
44 return;
45 }
46 const { isOn, warning } = await projectSwitch($, value);
47 setEnvOn(isOn);
48 if (warning !== undefined) {
49 addOnWarning(warning);
50 }
51};
52
53// §5.1: the version gate first, the command last. In `unsupported` no tool registers.
54const onSessionStart: Hook<'session.start'> = async ($, e, next) => {
55 const { base } = await $.session.version();
56 const isSupported = isSupportedVersion(base);
57 setMode(isSupported ? 'off' : 'unsupported');
58 await takeEnvSwitch($, isSupported && !e.isInteractive);
59 if (isSupported) {
60 await registerTools($);
61 }
62 await registerCommand($);
63 return next(e);
64};
65
66// §5.3: a Desktop attach counts only before the first prompt.
67const onPromptSubmit: Hook<'prompt.submit'> = async (_$, e, next) => {
68 notePrompt();
69 return next(e);
70};
71
72export const installLifecycle = (on: OnEvents<'session.start' | 'prompt.submit'>): void => {
73 on('session.start', onSessionStart);
74 on('prompt.submit', { text: /^/u }, onPromptSubmit);
75};
76hooks/log/install.ts 47 lines1import { watchdogOf } from '../agents/ids';
2import { watchdogBySlug } from '../agents/roster';
3import { currentLedger, tallyReview } from '../status/ledger';
4import { currentReviews, stopReasonOf } from '../stop/reviews';
5import { subagentOfReview } from '../subagents/watch';
6import { addLogRecord, countStep, currentLog, reviewRecord, takeTrace } from './log';
7import type { OnEvents } from '../on';
8import type { EngineInterface, TurnCompleteInput } from 'claude-code';
9
10// §13.4, §14.1: one record for each review, at the review agent's own `turn.complete`; `$.state` keeps the log
11// and the cost ledger across a reload, so a refused write loses only that carry-over. §13.3, §15: the ledger
12// counts the review. §7.8: the end of a review the mod stopped writes no record (a timeout wrote its own, and
13// `off`, `session` and `rewind` drop the result); it only counts in the ledger with its usage.
14const logReview = async ($: EngineInterface, end: TurnCompleteInput): Promise<void> => {
15 const slug = watchdogOf(end.agentId);
16 const watchdog = slug === undefined ? undefined : watchdogBySlug(slug);
17 if (watchdog === undefined || end.agentId === undefined) {
18 return;
19 }
20 const time = await $.clock.now();
21 const watch = subagentOfReview(end.agentId);
22 const subagent = watch === undefined ? undefined : { agentId: watch.agentId, type: watch.type };
23 const record = reviewRecord({ watchdog, agentId: end.agentId, time, end, trace: takeTrace(end.agentId), subagent });
24 tallyReview(watchdog.slug, record);
25 await $.state.set({ plugin: 'watchdog', key: 'ledger' }, currentLedger()).catch(() => undefined);
26 if (stopReasonOf(currentReviews(), end.agentId) === undefined) {
27 addLogRecord(record);
28 await $.state.set({ plugin: 'watchdog', key: 'log' }, currentLog());
29 }
30};
31
32// §12.1, §7.2: the review agent's steps are counted in a registration that did not spawn it (the spawning
33// `on('turn.step')` never sees them). The matchers only tell these `on()` from the review area's.
34export const installLog = (on: OnEvents<'turn.step' | 'turn.complete'>): void => {
35 on('turn.step', { turnId: /^/u }, async function* (_$, e, next) {
36 if (e.agentId !== undefined && watchdogOf(e.agentId) !== undefined) {
37 countStep(e.agentId);
38 }
39 return yield* next(e);
40 });
41 on('turn.complete', { turnId: /^/u }, async ($, e, next) => {
42 const result = await next(e);
43 await logReview($, e).catch(() => undefined);
44 return result;
45 });
46};
47hooks/note/install.ts 238 lines1import { watchdogBySlug } from '../agents/roster';
2import { addCard } from '../band/cards';
3import { DEFAULT_MAX_NOTES_PER_REVIEW } from '../constants';
4import { errorText } from '../errors';
5import { currentLog, traceNote } from '../log/log';
6import { isLateNote } from '../subagents/watch';
7import { batchTextOf, noteOf } from './call';
8import { UNSAFE_ROW, isUnsafeNote } from './destructive';
9import { dropHeldNote } from './drop';
10import { DROP_ACKS, judgeNote, normalizeNote, reviewSlots, setReviewSlots } from './guard';
11import {
12 EMPTY_HISTORY,
13 changeLiveHistory,
14 isRepeat,
15 liveHistory,
16 notesKey,
17 readHistory,
18 recordGuardKey,
19 recordNote,
20 setLiveHistory,
21 updateNote,
22 watchdogNotes,
23} from './history';
24import { deliveryFor, guardNote, heldNoteOf, holdNote, logRow, replaceHeldNote, watchHeldNotes } from './notes';
25import { SUPERSEDED, retract, retractionOf } from './retract';
26import type { OnEvents } from '../on';
27import type { NoteCall } from './call';
28import type { Verdict } from './guard';
29import type { NoteHistory } from './history';
30import type { HeldNote, Note } from './notes';
31import type { ResolveCall } from './retract';
32import type { Severity } from './tool';
33import type { EngineInterface, MatchedHook, ToolCallResult } from 'claude-code';
34
35type NoteHook = MatchedHook<'tool.call', { tool: 'mcp__watchdog__note' }>;
36
37type ResolveHook = MatchedHook<'tool.call', { tool: 'mcp__watchdog__resolve' }>;
38
39// §9.5: the ack of an admitted note.
40const ADMITTED = 'Queued. Do not re-raise.';
41
42// §9.6, §11.4: the live copy of `notes:<sessionId>` (or of a watched subagent's `notes:<sessionId>:<agentId>`)
43// loads at the first note hook of a session id, so a new process, a hot reload and a session change each load it
44// once. A load that another note finished first wins.
45const loadHistory = async ($: EngineInterface, agentId?: string): Promise<string> => {
46 const sessionId = await $.session.id();
47 const stored =
48 liveHistory(sessionId, agentId) === undefined ? await $.store.get(notesKey(sessionId, agentId)) : undefined;
49 if (liveHistory(sessionId, agentId) === undefined) {
50 setLiveHistory(sessionId, readHistory(stored), agentId);
51 }
52 return sessionId;
53};
54
55// §9.6, §14.2: write the live copy back with `lastUsed`. A refused write keeps the live copy; the store area
56// shows it.
57const saveHistory = async ($: EngineInterface, sessionId: string, agentId?: string): Promise<void> => {
58 const lastUsed = await $.clock.now();
59 const history: NoteHistory = { ...(liveHistory(sessionId, agentId) ?? EMPTY_HISTORY), lastUsed };
60 setLiveHistory(sessionId, history, agentId);
61 await $.store.set(notesKey(sessionId, agentId), history).catch(() => undefined);
62};
63
64// §9.1: the queued entry takes the higher severity in place, and the delivery state of that severity.
65const raiseHeld = (history: NoteHistory, queued: HeldNote, severity: Severity) => {
66 const raised = { ...queued, severity };
67 const shown: HeldNote = { ...raised, delivery: deliveryFor(raised) };
68 replaceHeldNote(queued, shown);
69 const change = { key: normalizeNote(queued.text), severity, delivery: shown.delivery };
70 return { shown, history: updateNote(history, queued.watchdog, change) };
71};
72
73// §9.4: the displaced note leaves the held list and the band, and the history and the review log mark it
74// `displaced`.
75const displaceHeld = (history: NoteHistory, note: Note, key: string | undefined): NoteHistory => {
76 const gone = key === undefined ? undefined : heldNoteOf(note, key);
77 if (gone === undefined || key === undefined) {
78 return history;
79 }
80 dropHeldNote(gone);
81 traceNote({ ...gone, delivery: 'displaced' });
82 return updateNote(history, note.watchdog, { key, delivery: 'displaced' });
83};
84
85// §9.6: a new note waits in the held list and joins the history.
86const holdNew = (history: NoteHistory, note: Note, displaced: string | undefined) => {
87 const kept = displaceHeld(history, note, displaced);
88 const shown: HeldNote = { ...note, delivery: deliveryFor(note) };
89 holdNote(shown);
90 addCard(shown);
91 return {
92 shown,
93 history: recordNote(kept, note.watchdog, { text: note.text, severity: note.severity, delivery: shown.delivery }),
94 };
95};
96
97// §9.2 to §9.4, §11.4: what the guard of the note's watchdog knows about the note's watched agent, and its
98// verdict.
99const judge = (history: NoteHistory, note: Note, key: string): Verdict =>
100 judgeNote(
101 { key, severity: note.severity },
102 {
103 seen: watchdogNotes(history, note.watchdog).keys.find((known) => known.key === key)?.severity,
104 slots: reviewSlots(note.watchdog, note.agentId),
105 budget: watchdogBySlug(note.watchdog)?.maxNotesPerReview ?? DEFAULT_MAX_NOTES_PER_REVIEW,
106 pendingSeverity: (pending) => heldNoteOf(note, pending)?.severity,
107 }
108 );
109
110// §9: an admitted note waits for its delivery, joins the live history of its watched agent and the review log,
111// and writes one row; the ack.
112const emitNote = ($: EngineInterface, sessionId: string, note: Note): string => {
113 const key = normalizeNote(note.text);
114 const history = liveHistory(sessionId, note.subagent?.agentId) ?? EMPTY_HISTORY;
115 const queued = heldNoteOf(note, key);
116 const verdict = judge(history, note, key);
117 if (verdict.kind === 'dropped') {
118 return DROP_ACKS[verdict.reason];
119 }
120 setReviewSlots(note.watchdog, note.agentId, verdict.slots);
121 const next =
122 verdict.kind === 'raised' && queued !== undefined
123 ? raiseHeld(history, queued, note.severity)
124 : holdNew(history, note, 'displaced' in verdict ? verdict.displaced : undefined);
125 setLiveHistory(sessionId, next.history, note.subagent?.agentId);
126 traceNote({ ...next.shown, agentId: note.agentId });
127 $.ui.log(logRow(next.shown, watchdogBySlug(note.watchdog)?.name ?? note.watchdog));
128 return ADMITTED;
129};
130
131// §11.4: a late note on a subagent goes to the primary agent, so it is first checked against the primary agent's
132// key set. A repeat there is dropped; an admitted note records its key there too.
133const isLateRepeat = async ($: EngineInterface, note: Note): Promise<boolean> => {
134 const sessionId = isLateNote(note) ? await loadHistory($) : undefined;
135 const entry = { key: normalizeNote(note.text), severity: note.severity };
136 return sessionId !== undefined && isRepeat(liveHistory(sessionId) ?? EMPTY_HISTORY, note.watchdog, entry);
137};
138
139const keepLateKey = async ($: EngineInterface, sessionId: string, note: Note): Promise<void> => {
140 const entry = { key: normalizeNote(note.text), severity: note.severity };
141 setLiveHistory(sessionId, recordGuardKey(liveHistory(sessionId) ?? EMPTY_HISTORY, note.watchdog, entry));
142 await saveHistory($, sessionId);
143};
144
145// §12.6: a note with a destructive command that its review's batch does not hold is dropped, with one row; then a
146// guard of another area may drop it. The drop's ack, or undefined for a note that goes on to the emission guard.
147const refusal = ($: EngineInterface, note: Note): ToolCallResult | undefined => {
148 if (isUnsafeNote(note.text, batchTextOf(note.agentId))) {
149 traceNote({ ...note, delivery: 'dropped:unsafe' });
150 $.ui.log(UNSAFE_ROW);
151 return { result: DROP_ACKS.unsafe };
152 }
153 const dropped = guardNote(note);
154 return dropped === undefined ? undefined : { result: dropped };
155};
156
157// §8.3, §12.6, §9: a note of a known watchdog passes the destructive check first, then the guards of other
158// areas, then the emission guard of its watched agent (§11.4); the history is written back after each
159// admitted note.
160const admitNote = async ($: EngineInterface, e: NoteCall): Promise<ToolCallResult> => {
161 const note = noteOf(e);
162 if ('deny' in note) {
163 return note;
164 }
165 const refused = refusal($, note);
166 if (refused !== undefined) {
167 return refused;
168 }
169 const isLate = isLateNote(note);
170 if (await isLateRepeat($, note)) {
171 return { result: DROP_ACKS.duplicate };
172 }
173 const sessionId = await loadHistory($, note.subagent?.agentId);
174 const ack = emitNote($, sessionId, note);
175 if (ack === ADMITTED) {
176 await saveHistory($, sessionId, note.subagent?.agentId);
177 }
178 if (ack === ADMITTED && isLate) {
179 await keepLateKey($, sessionId, note);
180 }
181 return { result: ack };
182};
183
184// §8.3: the hook answers without `next(e)`, so no permission check runs; it catches every error, because
185// a throw opens a permission dialog in front of the person.
186const onNote: NoteHook = async ($, e) => {
187 try {
188 return await admitNote($, e);
189 } catch (error) {
190 return { deny: `The note was not recorded: ${errorText(error)}` };
191 }
192};
193
194// §10.8: a retraction drops the open note and writes one row; the history of the note's watched agent and the review
195// log are written back. A refused write keeps the live copies; the store area shows a refused history write.
196const retractNote = async ($: EngineInterface, e: ResolveCall): Promise<ToolCallResult> => {
197 const asked = retractionOf(e);
198 if (!('note' in asked)) {
199 return asked;
200 }
201 const { note, reason } = asked;
202 const sessionId = await loadHistory($, note.subagent?.agentId);
203 const ack = retract(note, reason);
204 $.ui.log(logRow({ ...note, delivery: SUPERSEDED }, watchdogBySlug(note.watchdog)?.name ?? note.watchdog));
205 await saveHistory($, sessionId, note.subagent?.agentId);
206 await $.state.set({ plugin: 'watchdog', key: 'log' }, currentLog()).catch(() => undefined);
207 return { result: ack };
208};
209
210// §8.3, §10.8: the `resolve` hook answers like the `note` hook: without `next(e)`, and it catches every error.
211const onResolve: ResolveHook = async ($, e) => {
212 try {
213 return await retractNote($, e);
214 } catch (error) {
215 return { deny: `The note was not retracted: ${errorText(error)}` };
216 }
217};
218
219// §8.3: the `.catch` form of 2.1.290; it denies only a call from a watchdog agent or a fork. The tool
220// names are literals so that `claude plugin validate` lists the matchers. §7.7, §9.6: the recap shows the state each
221// held note has now (a new route, a delivery); the next write of the history keeps it.
222export const installNote = (on: OnEvents<'tool.call'>): void => {
223 on('tool.call', { tool: 'mcp__watchdog__note' }, onNote).catch((_$, e, next) =>
224 next.called || next.origin.plugin !== 'watchdog' || e.agentId === undefined
225 ? next(e)
226 : { deny: 'The note was not recorded.' }
227 );
228 on('tool.call', { tool: 'mcp__watchdog__resolve' }, onResolve).catch((_$, e, next) =>
229 next.called || next.origin.plugin !== 'watchdog' || e.agentId === undefined
230 ? next(e)
231 : { deny: 'The note was not retracted.' }
232 );
233 watchHeldNotes((_before, after) => {
234 const change = { key: normalizeNote(after.text), delivery: after.delivery };
235 changeLiveHistory(after.subagent?.agentId, (history) => updateNote(history, after.watchdog, change));
236 });
237};
238