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

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.
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.
/mods opens a panel listing every plugin with a hooks module, outermost in the chain first:
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.
mods-radar reads the engine's trace of every dispatch and reports, with a toast the first time:
next.to;It also lists the events several plugins hook, where they may cross.
$), and the panel says which plugins those are.{ 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.MIT
hooks/register.tsx 402 lines1import { 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}
402types/index.d.ts 48 lines1// 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