A mod for debugging mods: inspects the session's loaded mods, attributes hook failures (throws, timeouts, rejections) to the mod that caused them, times the…

Mods are TypeScript/JavaScript function hooks inside Claude Code plugins (Claude Code 2.1.287+). modscope is a mod for debugging them: it watches which mods load, wraps every engine event and attributes failures to the mod that caused them, times the hook chain per plugin, and hands all of it to you through /modscope, a status band, a pane — and to Claude itself through registered tools, so you can ask "why is my mod broken?" and Claude can inspect the session, read the mod's source and suggest a fix.
plugin.register hook records each hooks module with the host's own scan of it: name, version, root dir, tier (prepend/user/append/builtin), provenance (<name>@<marketplace>, @inline, @builtin), the exact events it hooks, the $. calls it makes, and the env vars it touches — the same data claude plugin validate prints. Refused registrations are recorded too.* hook wraps every event dispatch. After next(e) settles, next.trace lists each link in the chain with its plugin, outcome (caught, expired, kept, rejected, skipped, …) and wall time — so a throw, a 10-second budget overrun or a rejection lands on the mod responsible, with the event and a preview of its input. Rejected dispatches (where next(e) itself throws) are captured with the error message.mcp__modscope__* tools (list_mods, mod_errors, mod_stats, read_mod, validate_mod) are registered at session start, so the model can inspect the session's mods, read their failures and their actual source, and re-run claude plugin validate on a mod's directory./modscope fix <name> bundles a mod's observed failures plus its own source and asks the session's model ($.model.complete) for a root-cause analysis and concrete fix.AbovePrompt band shows loaded-mod and failure counts (turns red when failures appear); /modscope pane opens a per-mod health pane. Every captured failure is also appended to the debug log (claude --debug) under the plugin's name.Requires Claude Code 2.1.287 or later (claude --version). Mods are on by default.
/plugin marketplace add CommunityPokeOrg/claude-code-modscope
/plugin install modscope@community-poke-mods
/reload-plugins
git clone https://github.com/CommunityPokeOrg/claude-code-modscope
claude --plugin-dir claude-code-modscope/plugins/modscope
The folder is watched while the session runs, so editing hooks/modscope.mjs hot-reloads it in place — handy when debugging mods, including modscope itself.
/modscope report: mods, failures and event stats
/modscope mods every mod seen: tier, provenance, hooked events, engine calls
/modscope errors [n] attributed hook failures (throws, timeouts, rejections)
/modscope events event dispatch counts and failure counts
/modscope slow hook wall time per plugin, slowest first
/modscope pane toggle the modscope pane
/modscope validate [name] run `claude plugin validate` on mod root(s)
/modscope fix <name> ask the session's model to diagnose a failing mod
/modscope clear drop recorded mods and failures
Or just ask Claude:
"Why is the token-weather mod broken?" — Claude calls
mcp__modscope__mod_errorsto see its failures,mcp__modscope__read_modto read its source, and explains the fix."Which of my mods is slowing things down?" —
mcp__modscope__mod_stats.
/modscope errors — see which mod threw, on which event, with what outcome./modscope fix <name> — or ask Claude directly; it reads the mod's live source via read_mod./modscope validate <name> — re-check what the engine scans in the mod's source after your edit.Everything above is built from the documented function-hooks API:
| Mechanism | Used for |
|---|---|
on("plugin.register") + PluginRegisterInput.uses | Inventory of loaded mods with the host's scanned hook/capability list |
on("engine.create") (e.plugins) | The set of modules in the $ build fold |
on("*") + next.trace (TraceEntry.plugin/.outcome/.ms/.reason) | Per-plugin failure attribution and wall time on every dispatch |
$.command.register / command.run hook | /modscope and its subcommands |
$.tool.register / tool.call hook | mcp__modscope__* tools for the model |
$.ui.resolve + ui.render on AbovePrompt/Pane, $.ui.open/.close/.panes | Status band and health pane |
$.ui.log({ to: "debug" }) | Failure lines in the debug log |
$.store | Mod registry and failure ring survive hot reloads and sessions |
$.fs.read / $.process.run | Reading mod sources, running claude plugin validate |
$.model.complete | /modscope fix <name> diagnosis |
Module state lives in $.store where persistence matters (mods, failures); live counters are module-level and reset on hot reload.
claude plugin validate plugins/modscope # static scan: hooks, $ calls, env usage
claude plugin test plugins/modscope # run the tests in tests/ against the engine harness
Added Claude Mods in anthropics/claude-code CHANGELOG (v2.1.287)mods/ directory (diff, sec-default, telemetry, agents-md, types/claude-code.d.ts)next.trace attributes a failure by plugin name; the thrown error's message reaches modscope only when the whole dispatch rejects — otherwise the engine reports it by name to the transcript/debug log (modscope mirrors its findings there too). Run claude --debug for the deepest detail..claude-plugin/types/ — those are the authority.engine.create/plugin.register, and failures are attributed by next.trace rather than by wrapping order.hooks/modscope.mjs 574 lines1// modscope — a Claude Code mod that debugs other mods.
2//
3// Built on the verified function-hooks API (Claude Code 2.1.287+):
4// on("plugin.register") — sees every hooks module the engine loads, with the
5// host's own scan of what it hooks and calls (PluginRegisterInput.uses).
6// on("engine.create") — the fold that builds `$`; e.plugins lists the
7// modules in the build.
8// on("*") — wraps every dispatch. Once next(e) settles,
9// next.trace attributes each link to a plugin with an outcome and wall
10// time, so throws, budget timeouts and rejections land on the mod that
11// caused them.
12// $.command.register — /modscope: report, errors, stats, validator, pane,
13// and "fix <name>" (model-assisted diagnosis).
14// $.tool.register — mcp__modscope__* tools so Claude itself can inspect
15// the session's mods, read their failures and their source.
16
17const PANE_ID = 'modscope'
18const COMMAND = 'modscope'
19const SELF = 'modscope'
20const MAX_FAILURES = 200
21const INPUT_PREVIEW = 400
22const SOURCE_LIMIT = 60000
23const REPORT_ROWS = 25
24
25// Every link outcome that means a hook failed, per the TraceOutcome docs:
26// caught (threw, .catch answered), expired (budget ran out), kept (failed
27// after next), rejected, and skipped-without-reason (failed before next; a
28// reason means a next.to bypass, which is not a failure).
29const FAILURE_OUTCOMES = new Set(['caught', 'expired', 'kept', 'rejected'])
30
31const TOOLS = [
32 {
33 name: 'list_mods',
34 description:
35 "List the mods (plugins with hooks modules) loaded in this Claude Code session: name, version, root directory, tier, provenance, and the host-scanned list of the events each one hooks and the engine calls it makes — the same data `claude plugin validate` prints. Use to inspect which mods are installed and what each one can touch.",
36 inputSchema: { type: 'object' },
37 },
38 {
39 name: 'mod_errors',
40 description:
41 "Recent hook failures observed across the session's loaded mods — throws, budget timeouts, post-next failures and rejections — attributed to the plugin that caused them, with the event, outcome, wall time and a preview of the event input. Use to find which mod is misbehaving.",
42 inputSchema: {
43 type: 'object',
44 properties: {
45 plugin: { type: 'string', description: 'Only failures attributed to this mod.' },
46 },
47 },
48 },
49 {
50 name: 'mod_stats',
51 description:
52 "Event and plugin timing statistics gathered by wrapping every engine dispatch: event counts and failure counts, plus total hook wall time per plugin (slowest mods first).",
53 inputSchema: { type: 'object' },
54 },
55 {
56 name: 'read_mod',
57 description:
58 "Read a loaded mod's own files — its hooks/hooks.json manifest and each hooks module it declares — to inspect or debug its source code.",
59 inputSchema: {
60 type: 'object',
61 properties: {
62 name: { type: 'string', description: 'The mod name, as list_mods reports it.' },
63 },
64 required: ['name'],
65 },
66 },
67 {
68 name: 'validate_mod',
69 description:
70 "Run `claude plugin validate` on a loaded mod's directory and return the report: the hook registrations and engine calls its source is scanned as using.",
71 inputSchema: {
72 type: 'object',
73 properties: {
74 name: { type: 'string', description: 'The mod name; validates every known mod when omitted.' },
75 },
76 },
77 },
78]
79
80const TOOL_NAMES = TOOLS.map(t => `mcp__${SELF}__${t.name}`)
81
82const HELP = [
83 `/${COMMAND} — debug the session's loaded mods`,
84 ` /${COMMAND} report: mods, failures and event stats`,
85 ` /${COMMAND} mods every mod seen: tier, provenance, hooked events, engine calls`,
86 ` /${COMMAND} errors [n] attributed hook failures (throws, timeouts, rejections)`,
87 ` /${COMMAND} events event dispatch counts and failure counts`,
88 ` /${COMMAND} slow hook wall time per plugin, slowest first`,
89 ` /${COMMAND} pane toggle the modscope pane`,
90 ` /${COMMAND} validate [name] run \`claude plugin validate\` on mod root(s)`,
91 ` /${COMMAND} fix <name> ask the session's model to diagnose a failing mod`,
92 ` /${COMMAND} clear drop recorded mods and failures`,
93].join('\n')
94
95/** name -> registration record (PluginRegisterInput + outcome) */
96const mods = new Map()
97/** attributed failures, oldest first */
98const failures = []
99/** event name -> { count, failures } */
100const eventStats = new Map()
101/** plugin -> { ms, links }: hook-chain wall time from next.trace */
102const pluginTime = new Map()
103
104export function register(on) {
105 on('engine.create', async ($, e, next) => {
106 for (const name of e.plugins) noteModule(name)
107 return next(e)
108 })
109
110 on('plugin.register', async ($, e, next) => {
111 const result = await next(e)
112 noteRegistration(e, result)
113 persist($)
114 return result
115 })
116
117 on('*', async ($, e, next) => {
118 // At engine.create `$` is the empty table; there is nothing to inspect yet.
119 if (next.is('engine.create', e)) return next(e)
120 let result
121 try {
122 result = await next(e)
123 } catch (error) {
124 inspectTrace($, next)
125 recordFailure($, {
126 plugin: String(next.origin?.plugin ?? '(dispatch)'),
127 event: eventName(next),
128 outcome: 'rejected',
129 reason: messageOf(error),
130 })
131 throw error
132 }
133 inspectTrace($, next)
134 return result
135 })
136
137 on('session.start', async ($, e, next) => {
138 const result = await next(e)
139 await restore($).catch(() => undefined)
140 await $.command
141 .register({
142 name: COMMAND,
143 description: "Debug the session's loaded mods: failures, stats, sources, diagnosis.",
144 argumentHint: '[mods|errors|events|slow|pane|validate|fix|clear|help]',
145 })
146 .catch(error => $.ui.log(`modscope: command.register refused: ${messageOf(error)}`, { to: 'debug' }))
147 for (const tool of TOOLS) {
148 await $.tool
149 .register(tool)
150 .catch(error => $.ui.log(`modscope: tool.register ${tool.name} refused: ${messageOf(error)}`, { to: 'debug' }))
151 }
152 return result
153 })
154
155 on('session.end', async ($, e, next) => {
156 persist($)
157 return next(e)
158 })
159
160 on('command.run', { command: COMMAND }, async ($, e) => {
161 const args = e.args.trim().split(/\s+/).filter(Boolean)
162 try {
163 return { text: await runCommand($, args) }
164 } catch (error) {
165 return { text: `modscope: ${messageOf(error)}\n\n${HELP}` }
166 }
167 })
168
169 on('tool.call', { tool: TOOL_NAMES }, async ($, e) => {
170 try {
171 return { result: await serveTool($, e) }
172 } catch (error) {
173 return { result: `modscope: ${messageOf(error)}` }
174 }
175 })
176
177 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
178 const { Box, Text } = $.ui.resolve(e)
179 const below = await next(e)
180 const count = failures.length
181 return Box({
182 flexDirection: 'column',
183 children: [
184 below,
185 Text({
186 color: count > 0 ? 'red' : 'gray',
187 dimColor: count === 0,
188 children: `⛨ ${mods.size} mod${mods.size === 1 ? '' : 's'} · ${count} failure${count === 1 ? '' : 's'} · /${COMMAND}`,
189 }),
190 ],
191 })
192 })
193
194 on('ui.render', { component: 'Pane' }, ($, e, next) => {
195 if (e.requestId !== PANE_ID) return next(e)
196 const { Box, Text } = $.ui.resolve(e)
197 return Box({
198 flexDirection: 'column',
199 children: paneLines().map(line => Text({ children: line === '' ? ' ' : line })),
200 })
201 })
202}
203
204/* ---------------- recording ---------------- */
205
206function eventName(next) {
207 return String(next.event ?? '?')
208}
209
210function noteModule(name) {
211 const key = String(name)
212 if (mods.has(key)) return
213 mods.set(key, {
214 name: key,
215 root: '',
216 version: null,
217 provenance: '',
218 tier: '',
219 events: [],
220 calls: [],
221 admitted: true,
222 seenAt: Date.now(),
223 via: 'engine.create',
224 })
225}
226
227function noteRegistration(e, result) {
228 const uses = e.uses ?? {}
229 mods.set(e.name, {
230 name: String(e.name),
231 root: String(e.root ?? ''),
232 version: e.version ?? null,
233 provenance: String(e.provenance ?? ''),
234 tier: String(e.tier ?? ''),
235 events: Array.isArray(uses.events) ? uses.events.map(String) : [],
236 calls: Array.isArray(uses.calls) ? uses.calls.map(String) : [],
237 env: uses.env
238 ? {
239 reads: Array.isArray(uses.env.reads) ? uses.env.reads.map(String) : [],
240 writes: Array.isArray(uses.env.writes) ? uses.env.writes.map(String) : [],
241 }
242 : undefined,
243 admitted: result?.allow === true,
244 refused: typeof result?.refuse === 'string' ? result.refuse : undefined,
245 seenAt: Date.now(),
246 })
247}
248
249function inspectTrace($, next) {
250 const trace = next.trace
251 if (!Array.isArray(trace)) return
252 const event = eventName(next)
253 const stat = eventStats.get(event) ?? { count: 0, failures: 0 }
254 stat.count += 1
255 let bad = false
256 for (const link of trace) {
257 if (!link || typeof link !== 'object') continue
258 const plugin = String(link.plugin ?? 'engine')
259 if (plugin !== 'engine' && typeof link.ms === 'number') {
260 const t = pluginTime.get(plugin) ?? { ms: 0, links: 0 }
261 t.ms += link.ms
262 t.links += 1
263 pluginTime.set(plugin, t)
264 }
265 const outcome = String(link.outcome ?? '')
266 const failed = FAILURE_OUTCOMES.has(outcome) || (outcome === 'skipped' && !link.reason)
267 if (!failed) continue
268 bad = true
269 stat.failures += 1
270 recordFailure($, {
271 plugin,
272 event,
273 outcome,
274 reason: link.reason ? String(link.reason) : undefined,
275 ms: typeof link.ms === 'number' ? link.ms : undefined,
276 input: preview(link.received),
277 })
278 }
279 eventStats.set(event, stat)
280 if (bad) void $.store.set('failures', failures.slice(-MAX_FAILURES)).catch(() => undefined)
281}
282
283function recordFailure($, entry) {
284 failures.push({ ...entry, at: Date.now() })
285 if (failures.length > MAX_FAILURES) failures.splice(0, failures.length - MAX_FAILURES)
286 $.ui.log(
287 `modscope: ${entry.plugin} ${entry.event} ${entry.outcome}${entry.reason ? ` — ${entry.reason}` : ''}`,
288 { to: 'debug' },
289 )
290}
291
292function preview(value) {
293 if (value === null || typeof value !== 'object') return undefined
294 try {
295 const picked = {}
296 for (const key of ['tool', 'command', 'component', 'file_path', 'id']) {
297 if (key in value && typeof value[key] === 'string') picked[key] = value[key].slice(0, 120)
298 }
299 const source = Object.keys(picked).length > 0 ? picked : value
300 const text = JSON.stringify(source)
301 return text.length > INPUT_PREVIEW ? `${text.slice(0, INPUT_PREVIEW)}…` : text
302 } catch {
303 return undefined
304 }
305}
306
307function messageOf(error) {
308 return error instanceof Error ? error.message : String(error)
309}
310
311/* ---------------- persistence ---------------- */
312
313async function restore($) {
314 const savedMods = await $.store.get('mods').catch(() => undefined)
315 if (Array.isArray(savedMods)) {
316 for (const m of savedMods) {
317 if (m && typeof m === 'object' && typeof m.name === 'string' && !mods.has(m.name)) {
318 mods.set(m.name, m)
319 }
320 }
321 }
322 const savedFailures = await $.store.get('failures').catch(() => undefined)
323 if (Array.isArray(savedFailures)) {
324 for (const f of savedFailures) {
325 if (f && typeof f === 'object' && typeof f.event === 'string') failures.push(f)
326 }
327 if (failures.length > MAX_FAILURES) failures.splice(0, failures.length - MAX_FAILURES)
328 }
329}
330
331function persist($) {
332 void $.store.set('mods', [...mods.values()]).catch(() => undefined)
333 void $.store.set('failures', failures.slice(-MAX_FAILURES)).catch(() => undefined)
334}
335
336/* ---------------- reports ---------------- */
337
338async function runCommand($, args) {
339 const sub = args[0] ?? 'report'
340 switch (sub) {
341 case 'report':
342 return report()
343 case 'mods':
344 return modsReport()
345 case 'errors':
346 return errorsReport(args[1] ? Number(args[1]) : 15, args[2])
347 case 'events':
348 return eventsReport()
349 case 'slow':
350 return slowReport()
351 case 'pane':
352 return await togglePane($)
353 case 'validate':
354 return await validate($, args[1])
355 case 'fix':
356 return await fix($, args[1])
357 case 'clear':
358 return await clear($)
359 case 'help':
360 return HELP
361 default:
362 return `modscope: unknown subcommand "${sub}"\n\n${HELP}`
363 }
364}
365
366async function serveTool($, e) {
367 const short = String(e.tool).replace(`mcp__${SELF}__`, '')
368 switch (short) {
369 case 'list_mods':
370 return modsReport()
371 case 'mod_errors':
372 return errorsReport(25, typeof e.plugin === 'string' ? e.plugin : undefined)
373 case 'mod_stats':
374 return `${eventsReport()}\n\n${slowReport()}`
375 case 'read_mod':
376 return await readMod($, String(e.name ?? ''))
377 case 'validate_mod':
378 return await validate($, typeof e.name === 'string' ? e.name : undefined)
379 default:
380 return `modscope: unknown tool ${e.tool}`
381 }
382}
383
384function report() {
385 const bad = failures.length
386 const lines = [
387 `modscope report — ${mods.size} mod${mods.size === 1 ? '' : 's'} seen, ${bad} recorded failure${bad === 1 ? '' : 's'}`,
388 '',
389 ]
390 for (const mod of mods.values()) {
391 const flag = mod.refused ? ` REFUSED: ${mod.refused}` : mod.admitted ? '' : ' (outcome unknown)'
392 lines.push(
393 `• ${mod.name}${mod.version ? `@${mod.version}` : ''} [${mod.tier || '?'}] ${mod.provenance || mod.via || ''}${flag}`,
394 )
395 }
396 if (bad > 0) {
397 lines.push('', 'recent failures:')
398 for (const f of failures.slice(-8)) {
399 lines.push(` ${f.plugin} ${f.event} ${f.outcome}${f.reason ? ` — ${f.reason}` : ''}`)
400 }
401 }
402 lines.push('', `/${COMMAND} mods · errors · events · slow · pane · validate · fix <name>`)
403 return lines.join('\n')
404}
405
406function modsReport() {
407 if (mods.size === 0) return 'modscope: no mods seen yet this session.'
408 const lines = []
409 for (const mod of mods.values()) {
410 lines.push(`${mod.name}${mod.version ? `@${mod.version}` : ''}`)
411 lines.push(` root: ${mod.root || '?'} tier: ${mod.tier || '?'} provenance: ${mod.provenance || '?'}`)
412 if (mod.refused) lines.push(` REFUSED: ${mod.refused}`)
413 if (mod.events?.length) lines.push(` hooks: ${mod.events.join(', ')}`)
414 if (mod.calls?.length) lines.push(` calls: ${mod.calls.join(', ')}`)
415 if (mod.env && (mod.env.reads.length || mod.env.writes.length)) {
416 lines.push(` env: reads [${mod.env.reads.join(', ')}] writes [${mod.env.writes.join(', ')}]`)
417 }
418 }
419 return lines.join('\n')
420}
421
422function errorsReport(limit = 15, plugin) {
423 const rows = plugin ? failures.filter(f => f.plugin === plugin) : failures
424 if (rows.length === 0) {
425 return plugin ? `modscope: no failures recorded for "${plugin}".` : 'modscope: no failures recorded.'
426 }
427 const lines = [`${rows.length} failure${rows.length === 1 ? '' : 's'}${plugin ? ` for ${plugin}` : ''} (newest last):`]
428 for (const f of rows.slice(-limit)) {
429 const when = f.at ? new Date(f.at).toISOString().slice(11, 19) : '?'
430 lines.push(`[${when}] ${f.plugin} ${f.event} ${f.outcome}${typeof f.ms === 'number' ? ` ${Math.round(f.ms)}ms` : ''}`)
431 if (f.reason) lines.push(` ${f.reason}`)
432 if (f.input) lines.push(` input: ${f.input}`)
433 }
434 return lines.join('\n')
435}
436
437function eventsReport() {
438 if (eventStats.size === 0) return 'modscope: no events seen yet.'
439 const rows = [...eventStats.entries()].sort((a, b) => b[1].count - a[1].count).slice(0, REPORT_ROWS)
440 const lines = ['event dispatches (count · failures):']
441 for (const [event, s] of rows) {
442 lines.push(` ${String(s.count).padStart(6)} ${String(s.failures).padStart(4)} ${event}`)
443 }
444 return lines.join('\n')
445}
446
447function slowReport() {
448 if (pluginTime.size === 0) return 'modscope: no plugin timing yet.'
449 const rows = [...pluginTime.entries()].sort((a, b) => b[1].ms - a[1].ms).slice(0, REPORT_ROWS)
450 const lines = ['hook wall time per plugin (next.trace):']
451 for (const [plugin, t] of rows) {
452 lines.push(` ${String(Math.round(t.ms)).padStart(8)}ms ${String(t.links).padStart(6)} links ${plugin}`)
453 }
454 return lines.join('\n')
455}
456
457async function togglePane($) {
458 const open = (await $.ui.panes()).some(p => p.id === PANE_ID)
459 if (open) {
460 await $.ui.close({ id: PANE_ID })
461 return 'modscope: pane closed'
462 }
463 await $.ui.open({ id: PANE_ID, title: 'modscope — mod debugger' })
464 return 'modscope: pane opened (Esc closes it)'
465}
466
467function paneLines() {
468 const lines = [`modscope — ${mods.size} mods · ${failures.length} failures`, '']
469 for (const mod of mods.values()) {
470 const bad = failures.filter(f => f.plugin === mod.name).length
471 lines.push(
472 `${bad > 0 ? '✗' : '✓'} ${mod.name}${mod.version ? `@${mod.version}` : ''} [${mod.tier || '?'}] ${bad ? `${bad} failures` : ''}`,
473 )
474 }
475 if (failures.length > 0) {
476 lines.push('', 'latest failures:')
477 for (const f of failures.slice(-6)) {
478 lines.push(` ${f.plugin} ${f.event} ${f.outcome}${f.reason ? ` — ${f.reason.slice(0, 80)}` : ''}`)
479 }
480 }
481 lines.push('', `/${COMMAND} help`)
482 return lines
483}
484
485async function validate($, name) {
486 const targets = name
487 ? [mods.get(name)].filter(Boolean)
488 : [...mods.values()].filter(m => m.root)
489 if (targets.length === 0) {
490 return name
491 ? `modscope: no mod named "${name}" is loaded.`
492 : 'modscope: no mods with a known root directory.'
493 }
494 const out = []
495 for (const mod of targets.slice(0, 10)) {
496 const run = await $.process
497 .run(['claude', 'plugin', 'validate', '--json', mod.root], { timeoutMs: 60000 })
498 .catch(error => ({ error: messageOf(error) }))
499 out.push(`== ${mod.name} (${mod.root}) ==`)
500 out.push(run.error ? `validate failed to run: ${run.error}` : (run.stdout || '(no output)').slice(0, 4000))
501 }
502 return out.join('\n')
503}
504
505async function readMod($, name) {
506 const mod = mods.get(name)
507 if (!mod) {
508 return `modscope: no mod named "${name}" is loaded. Known: ${[...mods.keys()].join(', ') || 'none'}`
509 }
510 if (!mod.root) return `modscope: no root directory known for "${name}".`
511 const manifest = await $.fs.read(`${mod.root}/hooks/hooks.json`)
512 const chunks = [`${mod.root}/hooks/hooks.json`, manifest]
513 try {
514 const parsed = JSON.parse(manifest)
515 const modules = Array.isArray(parsed.modules) ? parsed.modules : []
516 for (const m of modules.slice(0, 4)) {
517 const rel = String(m).replace(/^\.\//, '')
518 const path = `${mod.root}/hooks/${rel}`
519 const source = await $.fs.read(path)
520 chunks.push(
521 `${path}`,
522 source.length > SOURCE_LIMIT ? `${source.slice(0, SOURCE_LIMIT)}\n…[truncated]` : source,
523 )
524 }
525 } catch (error) {
526 chunks.push(`(source read failed: ${messageOf(error)})`)
527 }
528 return chunks.join('\n\n')
529}
530
531async function fix($, name) {
532 if (!name) return `usage: /${COMMAND} fix <mod-name>`
533 const mod = mods.get(name)
534 if (!mod) {
535 return `modscope: no mod named "${name}" is loaded. Known: ${[...mods.keys()].join(', ') || 'none'}`
536 }
537 const modFailures = failures.filter(f => f.plugin === name)
538 const source = mod.root
539 ? await readMod($, name).catch(error => `(could not read mod files: ${messageOf(error)})`)
540 : '(root directory unknown)'
541 const prompt = [
542 `You are debugging a Claude Code mod — a plugin whose TypeScript/JavaScript hooks module intercepts engine events via register(on) with hooks of the form on(event, matcher?, ($, e, next) => result).`,
543 ``,
544 `Mod: ${name} (provenance ${mod.provenance || '?'}, tier ${mod.tier || '?'}, root ${mod.root || '?'})`,
545 `Hooked events: ${mod.events?.join(', ') || '?'}`,
546 `Engine calls used: ${mod.calls?.join(', ') || '?'}`,
547 ``,
548 `Observed hook failures attributed to this mod (${modFailures.length}, newest last):`,
549 JSON.stringify(modFailures.slice(-10), null, 2),
550 ``,
551 `Mod files:`,
552 source,
553 ``,
554 `Explain the most likely root cause of the failures and suggest a concrete fix, referencing the specific code.`,
555 ].join('\n')
556 const text = await $.model.complete({
557 model: 'haiku',
558 prompt,
559 maxTokens: 2048,
560 system: 'You are a precise debugger of Claude Code mods. Be concrete: name the code, the failure mode, and the fix.',
561 })
562 return `modscope diagnosis for ${name}:\n\n${text}`
563}
564
565async function clear($) {
566 mods.clear()
567 failures.length = 0
568 eventStats.clear()
569 pluginTime.clear()
570 await $.store.delete('mods').catch(() => undefined)
571 await $.store.delete('failures').catch(() => undefined)
572 return 'modscope: cleared.'
573}
574