SLOPSHOPPER

watchtower

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…

newbandcommandprocesstimer
v0.1.0MITupdated 2026-10-09MDmubarak786/claude-mods/mods/watchtower
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · watchtower
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /watchtower ⎿ watchtower: watchtower ⎿ watchtower: fence not loaded ⎿ watchtower: pins not loaded ⎿ watchtower: circuit-breaker not loaded ⎿ watchtower: red-green not loaded ⎿ watchtower: blocked nothing this session ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

watchtower

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.

Install

/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.

What each part means

PartShown whenFrom
fence src +1A fence is set for this project. The first allowed path, and how many more.fence
pins 2The project has pinned instructions.pins
breaker 3circuit-breaker is loaded: the threshold, or breaker off.circuit-breaker
tests: npm test, then tests ✔, tests ✘, tests skipped, or tests not runred-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 1A guard mod refused a call this session: fence, right-tool, pkg-guard, tripwire, circuit-breaker, or style-cop.Each refusal
1 unverified claimtrust-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.

CommandWhat it does
/watchtowerThe full status, one mod per line, including the ones that aren't loaded.
/watchtower offHide the band. Remembered across sessions.
/watchtower onShow it again.

What it touches

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
  • Reads the other mods' saved settings. fence, pins, circuit-breaker, and red-green keep their settings in their own small JSON files under ~/.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.

Tested with

  • Claude Code 2.1.295, with 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.

Limitations

  • Reading other mods' store files depends on how Claude Code lays them out today: one JSON file per plugin, named <plugin>_<marketplace>-<hash>.json. If that changes in a future release, the settings parts disappear until watchtower is updated; the session counts keep working.
  • Files from this repository's 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.
  • Counts start when the session starts. A refusal from an earlier session isn't counted.
  • The test result is the last red-green verdict seen as a row in this session. A run in another session isn't shown.
  • Only one line of the band is used, so in a very narrow window it may show the name and nothing else.

License

MIT, see the repository root.

Source 2 files
hooks/register.ts 308 lines
1// 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}
308
types/index.d.ts 29 lines
1// 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