SLOPSHOPPER

mods-radar

Which plugin hooks modules are loaded and working right now, and where they get in each other's way; /mods for the details

panecommandtoasttimer
v0.1.1MITupdated 2026-10-19Darvl-52/mods-radar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mods-radar
│ ┃ Моды ✕ › 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 │ │ › /mods │ ⎿ mods-radar: Моды: панель открыта. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Моды
Модули с хуками, сверху внешние Мешают друг другу Пока ничего не замечено.
README

mods-radar

English · Русский

A Claude Code mod that shows which plugin hooks modules are loaded and actually working in this session, and where they get in each other's way.

You install a mod and can't tell whether it runs at all: it may be enabled but never loaded, loaded but never called, or failing quietly. And when several mods hook the same events, one can stop another from running without anyone noticing. mods-radar watches every dispatch and tells you.

The panel's text is in Russian.

Install

At the prompt of a Claude Code terminal session:

/plugin install mods-radar --marketplace Darvl-52/mods-radar

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

From a shell:

claude plugin install mods-radar --marketplace Darvl-52/mods-radar

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Darvl-52/mods-radar, then Add plugin.

Use

/mods opens a panel listing every plugin with a hooks module, outermost in the chain first:

  • green: seen working in this session (its hooks ran, or it called the engine);
  • yellow: loaded, no work seen yet;
  • red: enabled in settings with a hooks module but not loaded, refused by another plugin, or its hooks fail.

For each one: version, how many times it ran, the last event and when, and the events it hooks. Plugins with no hooks module (skills, MCP, LSP) are listed on one line below.

Conflicts

mods-radar reads the engine's trace of every dispatch and reports, with a toast the first time:

  • cut off: a plugin answered an event itself, so a plugin beneath it that has run on that same call before (same event, same tool) did not run this time;
  • bypassed: a plugin jumped past another's tier with next.to;
  • rewrote: a plugin changed an event's input before another plugin saw it.

It also lists the events several plugins hook, where they may cross.

Limits

  • It sees the hooks of plugins beneath it in the chain. For plugins above it, it sees only their calls on the engine ($), and the panel says which plugins those are.
  • A hook's matcher ({ tool: 'Bash' }) cannot be read from outside, so a plugin counts as cut off only on a call it was already seen running on. A plugin cut off from its very first call goes unreported.
  • What each plugin ran on is kept in memory and starts over when mods-radar reloads.

License

MIT

Source 2 files
hooks/register.tsx 402 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TraceEntry } from 'claude-code'
3
4import type { Conflict, Mod } from '../types'
5
6const ME = 'mods-radar'
7const PANE = 'mods-radar'
8const mods = atom({ plugin: 'mods-radar', key: 'mods' } as const, {})
9const conflicts = atom({ plugin: 'mods-radar', key: 'conflicts' } as const, [])
10const order = atom({ plugin: 'mods-radar', key: 'order' } as const, [])
11const others = atom({ plugin: 'mods-radar', key: 'others' } as const, [])
12
13// Events the activity hook passes straight through: its own writes, timers and logging would
14// otherwise count as work, and the loads are hooked by name below.
15const QUIET = ['state.*', 'store.*', 'clock.*', 'telemetry.*', 'engine.create', 'plugin.register']
16// Events a plugin answers by design, under a matcher this mod cannot see (a command of its own):
17// an answer there cuts nobody off.
18const ANSWERED = ['command.*', 'config.describe', 'tool.describe', 'skill.prompt', 'session.*']
19const FAILED = new Set(['caught', 'expired', 'kept'])
20const NOT_A_PLUGIN = new Set(['engine', 'client', ME])
21
22export const matches = (pattern: string, event: string): boolean =>
23  pattern.startsWith('!')
24    ? !matches(pattern.slice(1), event)
25    : pattern === '*' || pattern === event || (pattern.endsWith('.*') && event.startsWith(pattern.slice(0, -1)))
26
27// A chain entry names a module by its plugin's name, or by a path inside the plugin's folder
28// (`\` between the parts on Windows).
29export const nameOf = (entry: string): string => {
30  const parts = entry.split(/[\\/]/).filter(Boolean)
31  if (parts.length < 2) return entry.replace(/@.*$/, '')
32  const at = parts.lastIndexOf('hooks')
33  const folder = at > 0 ? parts.slice(0, at) : parts.slice(0, -1)
34  // An installed plugin sits under its version folder: cache/<market>/<name>/<version>.
35  const cache = folder.lastIndexOf('cache')
36  return (cache >= 0 && folder.length >= cache + 3 ? folder[cache + 2] : folder[folder.length - 1]) ?? entry
37}
38
39// A path under a directory, joined with the separator that directory already uses.
40export const under = (dir: string, ...names: string[]): string =>
41  [dir.replace(/[\\/]+$/, ''), ...names].join(dir.includes('\\') ? '\\' : '/')
42
43// One directory however it is spelled; a drive letter or a `\` means Windows, where case does not count.
44export const samePath = (a: string, b: string): boolean => {
45  const plain = (p: string) => p.replace(/[\\/]+/g, '/').replace(/(.)\/$/, '$1')
46  const isWindows = [a, b].some(p => /^[A-Za-z]:/.test(p) || p.includes('\\'))
47  return isWindows ? plain(a).toLowerCase() === plain(b).toLowerCase() : plain(a) === plain(b)
48}
49
50// The config folder an installed plugin sits in: <config>/plugins/cache/<market>/<name>/<version>.
51export const configOf = (root: string): string | undefined => root.match(/^(.+)[\\/]plugins[\\/]cache[\\/][^\\/]+[\\/][^\\/]+/)?.[1]
52
53export type Install = { scope?: string; projectPath?: string; installPath?: string; version?: string }
54
55// A plugin installed in several scopes has an entry each: this project's own, else one for every project.
56export const installOf = (entries: readonly Install[] | undefined, project: string | undefined): Install | undefined =>
57  entries?.find(i => i.projectPath !== undefined && project !== undefined && samePath(i.projectPath, project)) ??
58  entries?.find(i => i.projectPath === undefined) ??
59  entries?.[0]
60
61export type Link = Pick<TraceEntry, 'index' | 'plugin' | 'outcome' | 'reason'> & { received: unknown }
62export type Finding = { kind: Conflict['kind']; by: string; victim: string; event: string }
63
64const same = (a: unknown, b: unknown): boolean => {
65  if (a === b) return true
66  try {
67    return JSON.stringify(a) === JSON.stringify(b)
68  } catch {
69    return true
70  }
71}
72
73// What one dispatch's trace says about plugins getting in each other's way. `event` carries
74// the call's subject (`tool.call Edit`); `ranOn` is what each plugin's hooks were seen running on.
75export const findConflicts = (
76  event: string,
77  links: readonly Link[],
78  chain: readonly string[],
79  ranOn: Readonly<Record<string, ReadonlySet<string>>>,
80): Finding[] => {
81  const name = event.split(' ')[0] ?? event
82  const sorted = [...links].sort((a, b) => a.index - b.index)
83  const found: Finding[] = []
84  for (const link of sorted) {
85    const by = link.reason?.match(/^bypassed by (.+)$/)?.[1]
86    if (link.outcome === 'skipped' && by !== undefined && !NOT_A_PLUGIN.has(link.plugin))
87      found.push({ kind: 'bypassed', by, victim: link.plugin, event })
88  }
89  for (const [i, b] of sorted.entries()) {
90    const a = sorted[i - 1]
91    if (a === undefined || NOT_A_PLUGIN.has(a.plugin) || NOT_A_PLUGIN.has(b.plugin) || a.outcome === 'skipped') continue
92    if (!same(a.received, b.received)) found.push({ kind: 'rewrote', by: a.plugin, victim: b.plugin, event })
93  }
94  // The trace ends short of the engine where a link answered without passing on: whoever sits
95  // beneath it never ran. A hook's matcher (`{ tool: 'Bash' }`) is not to be read from outside,
96  // so only a plugin already seen running on this very event and subject counts as cut off.
97  const last = sorted[sorted.length - 1]
98  const at = last === undefined ? -1 : chain.indexOf(last.plugin)
99  if (last !== undefined && at >= 0 && last.outcome === 'returned' && !ANSWERED.some(p => matches(p, name))) {
100    for (const victim of chain.slice(at + 1))
101      if (victim !== ME && ranOn[victim]?.has(event)) found.push({ kind: 'shadowed', by: last.plugin, victim, event })
102  }
103  return found
104}
105
106export const failuresOf = (links: readonly Link[]): string[] =>
107  links
108    .filter(l => !NOT_A_PLUGIN.has(l.plugin) && (FAILED.has(l.outcome) || (l.outcome === 'skipped' && l.reason === undefined)))
109    .map(l => l.plugin)
110
111const blank = (name: string): Mod => ({ name, isExpected: false, isLoaded: false, events: [], runs: 0, failures: 0 })
112
113// A value an earlier version of this mod wrote may lack fields: fill them in on read.
114const shaped = (all: unknown): Record<string, Mod> => {
115  const out: Record<string, Mod> = {}
116  if (all === null || typeof all !== 'object') return out
117  for (const [name, mod] of Object.entries(all as Record<string, Partial<Mod>>)) out[name] = { ...blank(name), ...mod }
118  return out
119}
120
121export const kindWords: Record<Conflict['kind'], (c: Pick<Conflict, 'by' | 'victim' | 'event'>) => string> = {
122  shadowed: c => `${c.by} ответил на ${c.event} сам, ${c.victim} не запускался`,
123  bypassed: c => `${c.by} обошёл хук ${c.victim} на ${c.event}`,
124  rewrote: c => `${c.by} меняет ${c.event} до того, как его видит ${c.victim}`,
125}
126
127export type Health = 'ok' | 'idle' | 'broken'
128export const healthOf = (mod: Mod): Health =>
129  mod.refused !== undefined || (mod.isExpected && !mod.isLoaded) || mod.failures > 0 ? 'broken' : mod.runs > 0 ? 'ok' : 'idle'
130const COLOR = { ok: 'success', idle: 'warning', broken: 'error' } as const
131
132export const statusOf = (mod: Mod): string =>
133  mod.refused !== undefined
134    ? `не загружен: ${mod.refused}`
135    : mod.isExpected && !mod.isLoaded
136      ? 'включён, но не загружен'
137      : mod.failures > 0
138        ? `ошибки: ${mod.failures} (${mod.lastFailure ?? '?'})`
139        : mod.runs > 0
140          ? 'работает'
141          : 'загружен, работы ещё не видно'
142
143const clockText = (ms: number | undefined) => (ms === undefined ? '' : new Date(ms).toTimeString().slice(0, 8))
144
145// engine.create runs before `$` can write state: the chain waits here for session.start.
146let chain: string[] = []
147// Seen between flushes, merged into $.state at most once a second.
148let pendingRuns: Record<string, { runs: number; failures: number; lastAt: number; lastEvent: string; lastFailure?: string }> = {}
149let pendingFound: (Finding & { at: number })[] = []
150let pendingLoads: Record<string, Partial<Mod>> = {}
151let isFlushing = false
152// A /clear emptied $.state: the next flush looks at the settings again first.
153let isStale = false
154// The folders of the plugins seen loading, for finding the config folder they were installed in.
155const roots: string[] = []
156// What each plugin's hooks were seen running on (`tool.call Edit`), for telling who an answer cut off.
157const ranOn: Record<string, Set<string>> = {}
158
159const flush = async ($: EngineInterface) => {
160  if (isStale) {
161    isStale = false
162    await discover($).catch(() => {})
163  }
164  const runs = pendingRuns
165  const found = pendingFound
166  const loads = pendingLoads
167  pendingRuns = {}
168  pendingFound = []
169  pendingLoads = {}
170  isFlushing = false
171  await update($, mods, old => {
172    const all = shaped(old)
173    for (const [name, load] of Object.entries(loads)) all[name] = { ...(all[name] ?? blank(name)), ...load }
174    for (const [name, run] of Object.entries(runs)) {
175      const mod = all[name] ?? { ...blank(name), isLoaded: true }
176      all[name] = {
177        ...mod,
178        isLoaded: true,
179        runs: mod.runs + run.runs,
180        failures: mod.failures + run.failures,
181        lastAt: run.lastAt,
182        lastEvent: run.lastEvent,
183        lastFailure: run.lastFailure ?? mod.lastFailure,
184      }
185    }
186    return all
187  })
188  const fresh: string[] = []
189  await update($, conflicts, old => {
190    const list = Array.isArray(old) ? [...old] : []
191    for (const f of found) {
192      const key = `${f.kind}|${f.by}|${f.victim}|${f.event}`
193      const at = list.findIndex(c => c.key === key)
194      const seen = list[at]
195      if (seen !== undefined) list[at] = { ...seen, count: seen.count + 1, lastAt: f.at }
196      else {
197        list.push({ key, ...f, count: 1, lastAt: f.at, isNew: true })
198        fresh.push(kindWords[f.kind](f))
199      }
200    }
201    return list.slice(-100)
202  })
203  if (fresh.length > 0) $.ui.toast(`Моды мешают друг другу: ${fresh[0]}${fresh.length > 1 ? ` (+${fresh.length - 1})` : ''}. Подробности: /mods`)
204}
205
206const later = ($: EngineInterface) => {
207  if (isFlushing) return
208  isFlushing = true
209  $.clock.after(1000, () => void flush($).catch(() => (isFlushing = false)))
210}
211
212const noteRun = (plugin: string, event: string, at: number, failure?: string) => {
213  const run = (pendingRuns[plugin] ??= { runs: 0, failures: 0, lastAt: at, lastEvent: event })
214  run.runs += 1
215  run.lastAt = at
216  run.lastEvent = event
217  if (failure !== undefined) {
218    run.failures += 1
219    run.lastFailure = failure
220  }
221}
222
223// The installed plugins' list, in the config folder: CLAUDE_CONFIG_DIR, else the one the loaded
224// plugins were installed in, else ~/.claude by HOME or by USERPROFILE (Windows sets no HOME).
225const readInstalled = async ($: EngineInterface): Promise<Record<string, Install[]>> => {
226  const homes = [await $.env.get('HOME'), await $.env.get('USERPROFILE')].flatMap(h => (h ? [under(h, '.claude')] : []))
227  const found = [await $.env.get('CLAUDE_CONFIG_DIR'), ...[...roots, $.plugin.root].map(configOf), ...homes]
228  for (const dir of new Set(found.filter((d): d is string => d !== undefined && d !== ''))) {
229    const list = await $.fs
230      .read(under(dir, 'plugins', 'installed_plugins.json'))
231      .then(text => (JSON.parse(text) as { plugins?: Record<string, Install[]> }).plugins ?? {})
232      .catch(() => undefined)
233    if (list !== undefined) return list
234  }
235  return {}
236}
237
238// What should be loaded: plugins enabled in settings whose hooks.json names a module.
239const discover = async ($: EngineInterface) => {
240  if (chain.length > 0) await update($, order, () => chain)
241  const known = new Set(chain)
242  const settings = (await $.settings.read()) as { enabledPlugins?: Record<string, unknown> }
243  const enabled = Object.entries(settings.enabledPlugins ?? {})
244    .filter(([, isOn]) => isOn === true)
245    .map(([id]) => id)
246  const installed = await readInstalled($)
247  const project = await $.session.root().catch(() => undefined)
248  const plain: string[] = []
249  await update($, mods, old => {
250    const all = shaped(old)
251    for (const name of known) all[name] = { ...(all[name] ?? blank(name)), isLoaded: true }
252    return all
253  })
254  for (const id of enabled) {
255    const name = id.replace(/@.*$/, '')
256    const entry = installOf(installed[id], project)
257    const hooksFile = entry?.installPath === undefined ? '' : await $.fs.read(under(entry.installPath, 'hooks', 'hooks.json')).catch(() => '')
258    if (!/"modules"\s*:/.test(hooksFile) && !known.has(name)) {
259      plain.push(name)
260      continue
261    }
262    await update($, mods, old => {
263      const all = shaped(old)
264      const mod = all[name] ?? blank(name)
265      all[name] = { ...mod, isExpected: true, version: mod.version ?? entry?.version, source: mod.source ?? id }
266      return all
267    })
268  }
269  await update($, others, () => plain.sort())
270}
271
272export const register: Register = on => {
273  on('engine.create', ($, e, next) => {
274    // At this plugin's own reload the list holds it alone: keep the whole one from the load.
275    if (e.plugins.length > 1 || chain.length === 0) chain = e.plugins.map(nameOf)
276    return next(e)
277  })
278
279  on('plugin.register', async ($, e, next) => {
280    const result = await next(e)
281    pendingLoads[e.name] = {
282      version: e.version,
283      source: e.provenance,
284      events: [...e.uses.events],
285      isLoaded: result.refuse === undefined,
286      refused: result.refuse,
287    }
288    if (!chain.includes(e.name)) chain = [...chain, e.name]
289    if (!roots.includes(e.root)) roots.push(e.root)
290    return result
291  })
292
293  on('session.start', async ($, e, next) => {
294    await $.command.register({ name: 'mods', description: 'Какие моды работают и не мешают ли друг другу' })
295    await discover($).catch(() => {})
296    await flush($)
297    return next(e)
298  })
299
300  // A /clear empties $.state and starts no session.start: look again at the next flush, which
301  // the activity hook below asks for, and at every /mods, so that settings changed since are seen too.
302  on('session.end', ($, e, next) => {
303    if (e.reason === 'clear') isStale = true
304    return next(e)
305  })
306
307  on('command.run', { command: 'mods' }, async $ => {
308    isStale = true
309    await flush($)
310    await update($, conflicts, old => (Array.isArray(old) ? old.map(c => ({ ...c, isNew: false })) : []))
311    await $.ui.open({ id: PANE, title: 'Моды' })
312    return { text: 'Моды: панель открыта.' }
313  })
314
315  // Every dispatch but drawing: who raised it (a plugin's `$` call) and whose hooks ran beneath.
316  on('!ui.*', async ($, e, next) => {
317    const event = next.event
318    if (QUIET.some(p => matches(p, event))) return next(e)
319    const at = await $.clock.now()
320    const subject = e !== null && typeof e === 'object' ? ((e as { tool?: unknown }).tool ?? (e as { command?: unknown }).command) : undefined
321    const label = typeof subject === 'string' ? `${event} ${subject}` : event
322    if (!NOT_A_PLUGIN.has(next.origin.plugin) && next.origin.tier !== 'core') noteRun(next.origin.plugin, label, at)
323    try {
324      return await next(e)
325    } finally {
326      // What was seen is the mod's business alone: a slip here never reaches the dispatch.
327      try {
328      const links: Link[] = next.trace.map(t => ({ index: t.index, plugin: t.plugin, outcome: t.outcome, reason: t.reason, received: t.received }))
329      const failed = new Set(failuresOf(links))
330      for (const link of links)
331        if (!NOT_A_PLUGIN.has(link.plugin) && link.reason === undefined) {
332          noteRun(link.plugin, label, at, failed.has(link.plugin) ? `${label}: ${link.outcome}` : undefined)
333          ;(ranOn[link.plugin] ??= new Set()).add(label)
334        }
335      for (const f of findConflicts(label, links, chain, ranOn)) pendingFound.push({ ...f, at })
336      later($)
337      } catch {}
338    }
339  })
340
341  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
342    const { Box, Text } = $.ui.resolve(e)
343    const all = shaped(await read($, mods))
344    const chained = (await read($, order)) ?? []
345    const rank = (name: string) => (chained.includes(name) ? chained.indexOf(name) : chained.length)
346    const rows = Object.values(all).sort((a, b) => rank(a.name) - rank(b.name) || a.name.localeCompare(b.name))
347    const list = [...((await read($, conflicts)) ?? [])].sort((a, b) => b.lastAt - a.lastAt)
348    const plain = (await read($, others)) ?? []
349    const above = chained.slice(0, Math.max(0, chained.indexOf(ME)))
350    const shared: [string, string[]][] = []
351    for (const pattern of new Set(rows.flatMap(m => m.events))) {
352      if (pattern.startsWith('!') || pattern === '*' || pattern.startsWith('ui.')) continue
353      const who = rows.filter(m => m.events.includes(pattern)).map(m => m.name)
354      if (who.length > 1) shared.push([pattern, who])
355    }
356    return (
357      <Box flexDirection="column">
358        <Text bold>Модули с хуками, сверху внешние</Text>
359        {rows.map(m => (
360          <Box key={m.name} flexDirection="column">
361            <Text color={COLOR[healthOf(m)]} wrap="truncate">
362              ● {m.name}
363              {m.version ? ` ${m.version}` : ''} — {statusOf(m)}
364            </Text>
365            <Text wrap="truncate">
366              {'   '}
367              {m.runs} срабат.{m.lastAt !== undefined ? `, последнее ${m.lastEvent} в ${clockText(m.lastAt)}` : ''}
368              {m.events.length > 0 ? ` · хуки: ${m.events.join(', ')}` : ''}
369            </Text>
370          </Box>
371        ))}
372        {above.length > 0 && (
373          <Text color="warning" wrap="wrap">
374            Выше {ME} в цепочке: {above.join(', ')}. Их хуки отсюда не видны, видны только их вызовы $.
375          </Text>
376        )}
377        {plain.length > 0 && <Text wrap="wrap">Без хуков (скиллы, MCP, LSP): {plain.join(', ')}</Text>}
378        <Text> </Text>
379        <Text bold>Мешают друг другу</Text>
380        {list.length === 0 && <Text color="success">Пока ничего не замечено.</Text>}
381        {list.map(c => (
382          <Text key={c.key} color={c.kind === 'rewrote' ? 'warning' : 'error'} bold={c.isNew} wrap="wrap">
383            {c.isNew ? '★ ' : '⚠ '}
384            {kindWords[c.kind](c)} ×{c.count}, {clockText(c.lastAt)}
385          </Text>
386        ))}
387        {shared.length > 0 && (
388          <Box flexDirection="column">
389            <Text> </Text>
390            <Text bold>Общие события (могут пересекаться)</Text>
391            {shared.map(([pattern, who]) => (
392              <Text key={pattern} wrap="truncate">
393                {pattern}: {who.join(', ')}
394              </Text>
395            ))}
396          </Box>
397        )}
398      </Box>
399    )
400  })
401}
402
types/index.d.ts 48 lines
1// One plugin with a hooks module, as this session has seen it.
2export type Mod = {
3  name: string
4  version?: string
5  // `<name>@<marketplace>`, `<name>@inline`, ...; known once its load was seen.
6  source?: string
7  // Enabled in settings and its hooks.json names a module: it should be loaded.
8  isExpected: boolean
9  isLoaded: boolean
10  // Why the engine did not load it, when a plugin refused it.
11  refused?: string
12  // The event patterns it hooks, when its load was seen.
13  events: string[]
14  // Hook runs and `$` calls seen from it.
15  runs: number
16  lastAt?: number
17  lastEvent?: string
18  failures: number
19  lastFailure?: string
20}
21
22// One way one plugin got in another's way, counted per (kind, by, victim, event).
23export type Conflict = {
24  key: string
25  // shadowed: `by` answered without passing on, `victim` hooks the event beneath it;
26  // bypassed: `by` jumped past `victim`'s tier; rewrote: `victim` got `by`'s rewritten input.
27  kind: 'shadowed' | 'bypassed' | 'rewrote'
28  by: string
29  victim: string
30  event: string
31  count: number
32  lastAt: number
33  isNew: boolean
34}
35
36declare module 'claude-code' {
37  interface PluginState {
38    'mods-radar': {
39      mods: Record<string, Mod>
40      conflicts: Conflict[]
41      // The chain's modules, outermost first.
42      order: string[]
43      // Plugins enabled in settings with no hooks module (skills, MCP, LSP).
44      others: string[]
45    }
46  }
47}
48