SLOPSHOPPER

modscope

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…

newpanebandguardcommandtool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · modscope
│ ┃ modscope ✕ › fix the failing auth test and add an audit log call │ ┃ modscope — 0 mods · 0 failures │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ /modscope help ⏺ 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 │ │ › /modscope │ ⎿ modscope: modscope report — 0 mods seen, 0 recorded failures │ ⎿ modscope: │ ⎿ modscope: │ ⎿ modscope: /modscope mods · errors · events · slow · pane · valid │ │ ⟨Claude Code's own drawing⟩ ⛨ 0 mods · 0 failures · /modscope ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ⛨ 0 mods · 0 failures · /modscope
Pane · modscope
modscope — 0 mods · 0 failures /modscope help
README

modscope — a Claude Code mod that debugs other mods

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.

What it does

  • Sees every mod that loads. A 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.
  • Attributes failures to the mod that caused them. A * 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.
  • Times the chain. The same trace accumulates hook wall time per plugin — find out which mod is making every event slow.
  • Lets Claude debug. Five 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.
  • Diagnoses on demand. /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.
  • Shows status live. A one-line 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.

Install

Requires Claude Code 2.1.287 or later (claude --version). Mods are on by default.

From the marketplace (recommended)

/plugin marketplace add CommunityPokeOrg/claude-code-modscope
/plugin install modscope@community-poke-mods
/reload-plugins

From a local checkout

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.

Usage

/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_errors to see its failures, mcp__modscope__read_mod to read its source, and explains the fix.

"Which of my mods is slowing things down?" — mcp__modscope__mod_stats.

Debugging a mod workflow

  1. Install modscope and the mod under development.
  2. Reproduce the failure (use the mod, run the command, make the edit).
  3. /modscope errors — see which mod threw, on which event, with what outcome.
  4. /modscope fix <name> — or ask Claude directly; it reads the mod's live source via read_mod.
  5. /modscope validate <name> — re-check what the engine scans in the mod's source after your edit.

How it works (verified interfaces only)

Everything above is built from the documented function-hooks API:

MechanismUsed for
on("plugin.register") + PluginRegisterInput.usesInventory 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 hookmcp__modscope__* tools for the model
$.ui.resolve + ui.render on AbovePrompt/Pane, $.ui.open/.close/.panesStatus band and health pane
$.ui.log({ to: "debug" })Failure lines in the debug log
$.storeMod registry and failure ring survive hot reloads and sessions
$.fs.read / $.process.runReading 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.

Development

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

Sources

Notes and limits

  • 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.
  • The mods API is new and may change between releases; when Claude Code loads a mod it writes the exact type declarations for your build into .claude-plugin/types/ — those are the authority.
  • A modscope that loads after a misbehaving mod still sees everything: plugin inventory comes from engine.create/plugin.register, and failures are attributed by next.trace rather than by wrapping order.
Source 1 files
hooks/modscope.mjs 574 lines
1// 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