One line above the prompt showing what your guard mods are doing: the fence, pins, the breaker threshold, the test command and its last result, and every call…

One line above the prompt showing what your guard mods are doing right now. Claude Code draws nothing there by default, and the other mods in this repository each report in their own place: a dialog, a line under the answer, a command reply. watchtower gathers that into one line you can read at a glance, so you know a fence is limiting edits or a test run just failed without typing a command.
watchtower │ fence src +1 │ pins 2 │ breaker 3 │ tests ✘ │ blocked 3: right-tool 2, fence 1 │ 1 unverified claim
It shows only what's active and stays out of the way when nothing is, or while one of Claude Code's surveys holds the band. Anything else a mod draws in the band stays, below this line.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install watchtower@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/watchtower
It has nothing to show until at least one of the mods below is installed.
| Part | Shown when | From |
|---|---|---|
fence src +1 | A fence is set for this project. The first allowed path, and how many more. | fence |
pins 2 | The project has pinned instructions. | pins |
breaker 3 | circuit-breaker is loaded: the threshold, or breaker off. | circuit-breaker |
tests: npm test, then tests ✔, tests ✘, tests skipped, or tests not run | red-green has a test command and is on. Once a run is seen this session, its result: passed, failed, skipped because Claude changed a test-defining file, or unable to start. | red-green |
blocked 3: right-tool 2, fence 1 | A guard mod refused a call this session: fence, right-tool, pkg-guard, tripwire, circuit-breaker, or style-cop. | Each refusal |
1 unverified claim | trust-but-verify flagged a claim this session. | trust-but-verify |
In a narrow window, parts that don't fit are dropped from the end rather than wrapped.
| Command | What it does |
|---|---|
/watchtower | The full status, one mod per line, including the ones that aren't loaded. |
/watchtower off | Hide the band. Remembered across sessions. |
/watchtower on | Show it again. |
From claude plugin validate ./mods/watchtower:
hooks: session.start, command.run{command=watchtower}, session.append, turn.complete, ui.render{component=AbovePrompt}
calls: $.clock.after, $.clock.every, $.command.list (via readSettings), $.command.register, $.fs.list (via readSettings), $.fs.read (via storeOf), $.process.run (via ensurePaths), $.session.root (via ensurePaths), $.state.get, $.state.set, $.store.get, $.store.set, $.ui.log, $.ui.resolve
~/.claude/plugins/store/. watchtower finds those files with $.fs.list and reads them with $.fs.read. It reads only those four mods' files, and never writes to them. The settings it reads are paths, counts, a number, and a test command, not secrets.$.process.run runs printenv HOME once at session start to find that folder. Nothing else is run.session.append sees each row the conversation saves, and passes every one through unchanged. It counts refusals from tool results, and red-green and trust-but-verify verdicts from lines that start with those names. Claude's replies and your prompts are never counted, so quoting a verdict doesn't change the band. A slash command's row also triggers a re-read of the settings.$.command.list tells it which of the mods are loaded, so it never shows a part for a mod you don't have.$.clock re-reads the settings shortly after start and every 15 seconds, so a change made in another session shows up.$.store holds the hidden flag. No network.claude plugin validate --strict and claude plugin test passing, including mounted band tests at full and narrow widths. Loaded into a Desktop app 2.1.293 session through hot reloading; how the band looks there is still to be confirmed with a screenshot.<plugin>_<marketplace>-<hash>.json. If that changes in a future release, the settings parts disappear until watchtower is updated; the session counts keep working.modhub marketplace and from --plugin-dir copies are preferred. A copy from another marketplace is found only if that marketplace's name has no underscore.MIT, see the repository root.
hooks/register.ts 308 lines1// watchtower: one line above the prompt showing what your guard mods are doing.
2//
3// /watchtower the full status, one mod per line
4// /watchtower off hide the band (remembered across sessions)
5// /watchtower on show it again
6//
7// The line shows only what's active: the fence, how many pins, the breaker
8// threshold, the test command and its last result, the calls the guard mods
9// refused this session, and claims trust-but-verify couldn't back. With
10// nothing active, or while a survey holds the band, the band is left to
11// Claude Code and other mods.
12//
13// Settings come from the other mods' own store files under
14// ~/.claude/plugins/store, read only and never written. Activity comes from
15// the conversation's rows as they're saved (session.append), which every
16// refusal passes through, whatever order the mods load in.
17
18import { atom, read, update } from 'claude-code'
19import type { View } from '../types'
20
21// Mods whose refusals start with "<name>: " and are counted as blocked calls.
22const GUARDS = ['fence', 'right-tool', 'pkg-guard', 'tripwire', 'circuit-breaker', 'style-cop']
23const KNOWN = [...GUARDS, 'pins', 'red-green', 'trust-but-verify']
24const DEFAULT_THRESHOLD = 3
25const FIRST_REFRESH_MS = 800
26const REFRESH_EVERY_MS = 15_000
27const SEPARATOR = ' │ '
28// Rows that hold someone's words rather than a mod's verdict: Claude's replies and
29// the person's prompts. A verdict quoted there is not counted.
30const PROSE_DOORS = ['response', 'prompt']
31
32const EMPTY: View = { hidden: false, loaded: [], fence: null, pins: 0, breaker: null, tests: null, lastRun: null, blocked: {}, unverified: 0 }
33const view = atom({ plugin: 'watchtower', key: 'view' } as const, EMPTY)
34
35let home = ''
36let root = ''
37let pending = false
38
39type Segment = { text: string; color?: string }
40
41function escapeRe(s: string): string {
42 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
43}
44
45function relative(path: string): string {
46 return root && path.startsWith(root + '/') ? path.slice(root.length + 1) : path
47}
48
49// The project root and the home folder, looked up once and again if a lookup failed.
50async function ensurePaths($) {
51 if (!root) {
52 try {
53 root = await $.session.root()
54 } catch {
55 root = ''
56 }
57 }
58 if (!home) {
59 try {
60 home = (await $.process.run(['printenv', 'HOME'])).stdout.trim()
61 } catch {
62 home = ''
63 }
64 }
65}
66
67// The JSON store file a mod keeps, by its plugin name. Files are named
68// <plugin>_<marketplace>-<hash>.json. This repository's own marketplace and
69// --plugin-dir copies are preferred; any other marketplace without an underscore
70// in its name is the fallback. Newest first when there are several.
71async function storeOf($, entries, name: string): Promise<Record<string, unknown>> {
72 const n = escapeRe(name)
73 const preferred = new RegExp('^' + n + '_(?:modhub|inline)-[0-9a-f]+\\.json$')
74 const fallback = new RegExp('^' + n + '_[A-Za-z0-9.-]+-[0-9a-f]+\\.json$')
75 const files = entries.filter((x) => x.kind === 'file')
76 let candidates = files.filter((x) => preferred.test(x.name))
77 if (!candidates.length) candidates = files.filter((x) => fallback.test(x.name))
78 const match = candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
79 if (!match) return {}
80 try {
81 const data = JSON.parse(await $.fs.read(home + '/.claude/plugins/store/' + match.name))
82 return data && typeof data === 'object' ? data : {}
83 } catch {
84 return {}
85 }
86}
87
88// Re-read which mods are loaded and what they're set to. Session counts are kept.
89async function readSettings($) {
90 await ensurePaths($)
91 let loaded: string[] = []
92 try {
93 const plugins = new Set((await $.command.list()).map((c) => String(c.plugin ?? '').split('@')[0]))
94 loaded = KNOWN.filter((m) => plugins.has(m))
95 } catch {
96 loaded = []
97 }
98 let entries = []
99 try {
100 if (home) entries = await $.fs.list(home + '/.claude/plugins/store')
101 } catch {
102 entries = []
103 }
104
105 let fence: string[] | null = null
106 if (loaded.includes('fence')) {
107 const paths = (await storeOf($, entries, 'fence'))['fence:' + root]
108 if (Array.isArray(paths) && paths.length) fence = paths.map((p) => relative(String(p)))
109 }
110 let pins = 0
111 if (loaded.includes('pins')) {
112 const list = (await storeOf($, entries, 'pins'))['pins:' + root]
113 if (Array.isArray(list)) pins = list.length
114 }
115 let breaker: number | null = null
116 if (loaded.includes('circuit-breaker')) {
117 const t = (await storeOf($, entries, 'circuit-breaker')).threshold
118 breaker = typeof t === 'number' ? t : DEFAULT_THRESHOLD
119 }
120 let tests: View['tests'] = null
121 if (loaded.includes('red-green')) {
122 const s = (await storeOf($, entries, 'red-green'))['red-green:' + root] as { command?: unknown; enabled?: unknown } | undefined
123 if (s && typeof s.command === 'string' && s.command) tests = { command: s.command, enabled: s.enabled !== false }
124 }
125
126 await update($, view, (v) => {
127 const next = { ...v, loaded, fence, pins, breaker, tests }
128 return JSON.stringify(next) === JSON.stringify(v) ? v : next
129 })
130}
131
132// Timers and handlers call this; a failed read leaves the band as it was.
133async function refresh($) {
134 try {
135 await readSettings($)
136 } catch {
137 // Best effort: the next refresh tries again.
138 }
139}
140
141// Coalesce bursts (several command rows at once) into one refresh.
142function schedule($) {
143 if (pending) return
144 pending = true
145 $.clock.after(300, async () => {
146 pending = false
147 await refresh($)
148 })
149}
150
151function textOf(content: unknown): string {
152 if (typeof content === 'string') return content
153 if (Array.isArray(content)) return content.map((b) => (b && typeof b.text === 'string' ? b.text : '')).join('\n')
154 return ''
155}
156
157const VERDICT = /(?:^|Last run: )red-green: (tests passed|tests FAILED|skipped|could not run)[^\n]*/m
158const RESULT_OF = { 'tests passed': 'passed', 'tests FAILED': 'failed', skipped: 'skipped', 'could not run': 'error' } as const
159
160// Count what a saved row says the guard mods did.
161async function observe($, e) {
162 if (e.door === 'command') schedule($)
163 const prose = PROSE_DOORS.includes(e.door)
164 let blockedBy: string | null = null
165 let run: View['lastRun'] = null
166 let unverified = 0
167 for (const b of e.message.content ?? []) {
168 if (b.type === 'tool_result' && b.is_error) {
169 const m = /^\s*(?:<tool_use_error>)?\s*([a-z][a-z-]*): /.exec(textOf(b.content))
170 if (m && GUARDS.includes(m[1])) blockedBy = m[1]
171 }
172 // Verdict lines arrive as text: a red-green or trust-but-verify line under an
173 // answer, or a /red-green status reply. Never from Claude's or the person's words.
174 if (prose || b.type !== 'text') continue
175 const text = String(b.text ?? '')
176 const rg = VERDICT.exec(text)
177 if (rg) run = { result: RESULT_OF[rg[1]], text: rg[0].replace(/^Last run: /, '') }
178 const tv = /^trust-but-verify: [\s\S]*/m.exec(text)
179 if (tv) unverified += (tv[0].match(/✘/g) ?? []).length
180 }
181 if (!blockedBy && !run && !unverified) return
182 await update($, view, (v) => ({
183 ...v,
184 blocked: blockedBy ? { ...v.blocked, [blockedBy]: (v.blocked[blockedBy] ?? 0) + 1 } : v.blocked,
185 lastRun: run ?? v.lastRun,
186 unverified: v.unverified + unverified,
187 }))
188}
189
190function testSegment(v: View): Segment {
191 if (!v.lastRun) return { text: 'tests: ' + v.tests.command }
192 if (v.lastRun.result === 'passed') return { text: 'tests ✔', color: 'green' }
193 if (v.lastRun.result === 'failed') return { text: 'tests ✘', color: 'red' }
194 if (v.lastRun.result === 'skipped') return { text: 'tests skipped', color: 'yellow' }
195 return { text: 'tests not run', color: 'yellow' }
196}
197
198function segments(v: View): Segment[] {
199 const out: Segment[] = []
200 if (v.fence) out.push({ text: 'fence ' + v.fence[0] + (v.fence.length > 1 ? ' +' + (v.fence.length - 1) : ''), color: 'yellow' })
201 if (v.pins > 0) out.push({ text: 'pins ' + v.pins })
202 if (v.breaker !== null) out.push({ text: v.breaker > 0 ? 'breaker ' + v.breaker : 'breaker off' })
203 if (v.tests && v.tests.enabled) out.push(testSegment(v))
204 const blocked = Object.entries(v.blocked).sort((a, b) => b[1] - a[1])
205 const total = blocked.reduce((n, [, c]) => n + c, 0)
206 if (total > 0) out.push({ text: 'blocked ' + total + ': ' + blocked.map(([m, c]) => m + ' ' + c).join(', '), color: 'yellow' })
207 if (v.unverified > 0) out.push({ text: v.unverified + ' unverified claim' + (v.unverified === 1 ? '' : 's'), color: 'red' })
208 return out
209}
210
211// Keep whole segments that fit the band's width, in order.
212function fit(all: Segment[], columns: number): Segment[] {
213 let used = 'watchtower'.length
214 const kept: Segment[] = []
215 for (const s of all) {
216 const need = SEPARATOR.length + s.text.length
217 if (used + need > columns) break
218 used += need
219 kept.push(s)
220 }
221 return kept
222}
223
224function statusText(v: View): string {
225 const has = (m: string) => v.loaded.includes(m)
226 const line = (m: string, text: string) => ' ' + m.padEnd(17) + (has(m) ? text : 'not loaded')
227 const blocked = Object.entries(v.blocked).map(([m, c]) => m + ' ' + c).join(', ')
228 return [
229 'watchtower' + (v.hidden ? ' (band hidden: /watchtower on)' : ''),
230 line('fence', v.fence ? v.fence.join(', ') : 'no fence'),
231 line('pins', v.pins + ' pinned'),
232 line('circuit-breaker', v.breaker ? 'hold after ' + v.breaker + ' identical failures' : 'off'),
233 line('red-green', v.tests ? v.tests.command + (v.tests.enabled ? '' : ' (off)') + (v.lastRun ? '; last: ' + v.lastRun.text : '') : 'no test command'),
234 ' blocked ' + (blocked || 'nothing this session'),
235 ' unverified ' + v.unverified + ' this session',
236 ].join('\n')
237}
238
239export function register(on) {
240 on('session.start', async ($, e, next) => {
241 await ensurePaths($)
242 try {
243 if ((await $.store.get('hidden')) === true) await update($, view, (v) => ({ ...v, hidden: true }))
244 } catch {
245 // Shown by default.
246 }
247 try {
248 await $.command.register({ name: 'watchtower', description: 'What your guard mods are doing, shown above the prompt', argumentHint: '[on | off]', immediate: true })
249 } catch (error) {
250 $.ui.log('could not register /watchtower: ' + error)
251 }
252 // After the other mods have registered their commands.
253 $.clock.after(FIRST_REFRESH_MS, () => refresh($))
254 // Settings change in other sessions too: re-read them now and then.
255 $.clock.every(REFRESH_EVERY_MS, () => refresh($))
256 return next(e)
257 })
258
259 on('command.run', { command: 'watchtower' }, async ($, e) => {
260 const args = e.args.trim()
261 if (args === 'on' || args === 'off') {
262 const hidden = args === 'off'
263 await $.store.set('hidden', hidden)
264 await update($, view, (v) => ({ ...v, hidden }))
265 return { text: hidden ? 'Band hidden. /watchtower on shows it again.' : 'Band shown.' }
266 }
267 if (args) return { text: 'Usage: /watchtower, /watchtower on, or /watchtower off' }
268 await refresh($)
269 return { text: statusText(await read($, view)) }
270 }).catch(async () => ({ text: 'watchtower: the command failed.' }))
271
272 // Observe every saved row; never change one.
273 on('session.append', async ($, e, next) => {
274 const result = await next(e)
275 try {
276 await observe($, e)
277 } catch {
278 // Counting is best effort.
279 }
280 return result
281 }).catch(async ($, e, next) => next(e))
282
283 on('turn.complete', async ($, e, next) => {
284 schedule($)
285 return next(e)
286 }).catch(async ($, e, next) => next(e))
287
288 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
289 // A survey holds the band while it asks; yield to it.
290 if (e.props.hasSurvey) return next(e)
291 const v = await read($, view)
292 const all = v.hidden ? [] : segments(v)
293 if (!all.length) return next(e)
294 const { Box, Text } = $.ui.resolve(e)
295 const kept = fit(all, e.props.bodyColumns || 80)
296 const line = Box({
297 flexDirection: 'row',
298 children: [
299 Text({ bold: true, children: ['watchtower'] }),
300 ...kept.flatMap((s) => [Text({ dimColor: true, children: [SEPARATOR] }), Text(s.color ? { color: s.color, children: [s.text] } : { children: [s.text] })]),
301 ],
302 })
303 // Keep whatever mods after this one draw in the band, below this line.
304 const theirs = await next(e)
305 return theirs ? Box({ flexDirection: 'column', children: [line, theirs] }) : line
306 })
307}
308types/index.d.ts 29 lines1// What the band draws from. Settings are read from the other mods' store files;
2// the session fields are counted from the conversation's rows as they're saved.
3export type View = {
4 /** The person turned the band off with /watchtower off. */
5 hidden: boolean
6 /** The modhub mods loaded in this session, by plugin name. */
7 loaded: string[]
8 /** Paths fence allows, relative to the project root; null when no fence is set. */
9 fence: string[] | null
10 /** How many instructions pins holds for this project. */
11 pins: number
12 /** circuit-breaker's threshold; 0 when it's off, null when it isn't loaded. */
13 breaker: number | null
14 /** red-green's test command for this project, and whether it's on. */
15 tests: { command: string; enabled: boolean } | null
16 /** The last red-green verdict seen this session: its outcome and the line it came from. */
17 lastRun: { result: 'passed' | 'failed' | 'skipped' | 'error'; text: string } | null
18 /** Calls refused this session, by the mod that refused them. */
19 blocked: Record<string, number>
20 /** Claims trust-but-verify couldn't back this session. */
21 unverified: number
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 watchtower: { view: View }
27 }
28}
29