SLOPSHOPPER

mod-logic

/mod-logic shows how every mod works: the events it hooks, the capabilities it calls (tagged by risk) and what it registers, as a live 2D graph that redraws as…

newpaneguardcommandtoastprocess
v0.1.0MITupdated 2026-10-23THEROCKSSS/claude-mods/mods/mod-logic
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-logic
│ ┃ Mod Logic ✕ › fix the failing auth test and add an audit log call │ ┃ Reading your mods… │ ⏺ 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 │ │ › /mod-logic │ ⎿ mod-logic: Mod Logic: 0 mods, 0 hooks. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mod Logic
Reading your mods…
README

<img src=".claude-plugin/icon.png" width="72" align="right" alt="">

mod-logic

/mod-logic reads every installed mod's source and draws how it works:

  • the events it hooks
  • the capabilities it calls, coloured by risk: runs programs, uses the network, writes files, drives Claude, and so on
  • the commands, tools and state it registers

The 3D logic graph in the Mod Hub

What it does

  • A 2D map in the pane shows a breakdown per mod. Parts that two or more mods share are where they meet.
  • Live: edit a file inside a mod's folder and its part of the graph redraws within a second. Drafts in any .claude-plugin/ folder are picked up too.
  • 2D or 3D in the Mod Hub (#/logic), with filters by kind and a focus on one mod.
  • It flags guards that fail open: a hook that can throw without a .catch.

The analyzer, graph and layout live in shared/logic.ts, so the pane and the web app use the same code.

No settings.

Source 3 files
hooks/register.tsx 492 lines
1// Mod Logic: how each mod works, read off its source and drawn as a graph.
2//   - Every installed mod's hooks module (and the local files it imports) is read: the events
3//     it hooks, the $ capabilities it calls (tagged by risk), what it registers.
4//   - Live: when Claude edits a file inside a mod's folder, that mod is read again and the
5//     graph moves within a second. A new folder with a .claude-plugin/ joins as a draft.
6//   - /mod-logic: a pane with the 2D map (an Svg) and a breakdown per mod.
7//   - The graph goes to the Mod Hub too (when it runs), whose #/logic view draws 2D and 3D.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { LogicChange } from '../types'
12import { analyze, buildGraph, filterKinds, focusGraph, graphSvg, importsOf, RISKS, sharedOnly, type ModLogic, type NodeKind } from '../shared/logic'
13
14const PANE = 'mod-logic'
15const PER_PAGE = 4
16const DEBOUNCE_MS = 600
17const MAX_FILES = 40 // per mod: a hooks module and the helpers it imports
18const KINDS: { value: NodeKind; label: string }[] = [
19  { value: 'event', label: 'Events' },
20  { value: 'capability', label: 'Capabilities' },
21  { value: 'command', label: 'Commands' },
22  { value: 'tool', label: 'Tools' },
23  { value: 'state', label: 'State' },
24]
25
26const logic = atom({ plugin: 'mod-logic', key: 'logic' } as const, [] as ModLogic[])
27const changed = atom({ plugin: 'mod-logic', key: 'changed' } as const, null as LogicChange | null)
28const tab = atom({ plugin: 'mod-logic', key: 'tab' } as const, 'map' as 'map' | 'breakdown')
29const focus = atom({ plugin: 'mod-logic', key: 'focus' } as const, '')
30const kinds = atom({ plugin: 'mod-logic', key: 'kinds' } as const, ['event', 'capability', 'command'] as NodeKind[])
31const page = atom({ plugin: 'mod-logic', key: 'page' } as const, 0)
32const shared = atom({ plugin: 'mod-logic', key: 'shared' } as const, true)
33
34type Root = { id: string; name: string; dir: string; enabled: boolean }
35
36let roots: Root[] = []
37let drafts: Root[] = [] // folders with a .claude-plugin/ that Claude wrote this session, not installed
38const timers = new Map<string, { cancel: () => void }>()
39let sentTo = '' // the hub (pid and start time) that already has the graph
40let beat: { cancel: () => void } | undefined
41
42export const register: Register = on => {
43  roots = []
44  drafts = []
45  sentTo = ''
46
47  on('session.start', async ($, e, next) => {
48    const r = await next(e)
49    await $.command.register({ name: 'mod-logic', description: 'See how every mod works: what it hooks, calls and registers, as a live graph' }).catch(() => {})
50    void readAll($).catch(() => {})
51    // A hub opened after this session started has no graph yet: check for one now and then.
52    beat?.cancel()
53    beat = $.clock.every(10_000, () => {
54      void pushIfNew($).catch(() => {})
55    })
56    return r
57  })
58
59  on('command.run', { command: 'mod-logic' }, async ($, e) => {
60    const arg = e.args.trim()
61    if (arg) await update($, focus, () => ((roots.concat(drafts).find(x => x.name === arg)?.id) ?? ''))
62    await readAll($).catch(() => {})
63    await $.ui.open({ id: PANE, title: 'Mod Logic', focus: true })
64    const list = await read($, logic)
65    return { text: `Mod Logic: ${list.length} mods, ${list.reduce((n, m) => n + m.hooks.length, 0)} hooks.` }
66  })
67
68  // Live: a file Claude edits inside a mod's folder reads that mod again.
69  on('tool.call', async ($, e, next) => {
70    const r = await next(e)
71    if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'MultiEdit') return r
72    const path = String((e as unknown as { file_path?: unknown }).file_path ?? '').replace(/\\/g, '/')
73    if (path && !r.deny && !r.isError) void touched($, path).catch(() => {})
74    return r
75  })
76
77  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($, e))
78}
79
80// ── Finding mods and reading them ─────────────────────────────────────────
81
82const slash = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
83
84// a/b/../c → a/c, keeping a drive letter or a leading slash.
85function normalize(path: string): string {
86  const parts: string[] = []
87  for (const part of path.split('/')) {
88    if (part === '..') parts.pop()
89    else if (part !== '.' && part !== '') parts.push(part)
90  }
91  return (path.startsWith('/') ? '/' : '') + parts.join('/')
92}
93
94async function configDir($: EngineInterface): Promise<string> {
95  const config = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)
96  if (config) return slash(config)
97  const home = (await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined)) ?? ''
98  return `${slash(home)}/.claude`
99}
100
101async function readJson($: EngineInterface, path: string): Promise<any> {
102  const text = await $.fs.read(path).catch(() => '')
103  try {
104    return JSON.parse(typeof text === 'string' ? text : '')
105  } catch {
106    return undefined
107  }
108}
109
110// Each installed plugin's folder: a folder marketplace's own source folder (so edits there are
111// the ones that count), else the copy the install made.
112async function findRoots($: EngineInterface): Promise<Root[]> {
113  const dir = await configDir($)
114  const installed = ((await readJson($, `${dir}/plugins/installed_plugins.json`))?.plugins ?? {}) as Record<string, { installPath?: string }[]>
115  const markets = ((await readJson($, `${dir}/plugins/known_marketplaces.json`)) ?? {}) as Record<string, { source?: { source?: string; path?: string } }>
116  const enabled = ((await $.settings.read({ key: 'enabledPlugins' }).catch(() => ({}))) ?? {}) as Record<string, unknown>
117  const sourceDirs = new Map<string, Map<string, string>>() // marketplace → plugin name → folder
118  for (const [name, m] of Object.entries(markets)) {
119    if (m.source?.source !== 'directory' || !m.source.path) continue
120    const base = slash(m.source.path)
121    const manifest = await readJson($, `${base}/.claude-plugin/marketplace.json`)
122    const map = new Map<string, string>()
123    for (const p of (manifest?.plugins ?? []) as { name?: string; source?: unknown }[]) {
124      if (typeof p.name === 'string' && typeof p.source === 'string' && p.source.startsWith('.')) map.set(p.name, normalize(`${base}/${p.source}`))
125    }
126    sourceDirs.set(name, map)
127  }
128  const out: Root[] = []
129  for (const [id, rows] of Object.entries(installed)) {
130    const at = id.lastIndexOf('@')
131    const name = at > 0 ? id.slice(0, at) : id
132    const market = at > 0 ? id.slice(at + 1) : ''
133    const dirOf = sourceDirs.get(market)?.get(name) ?? (rows?.[0]?.installPath ? slash(rows[0].installPath) : '')
134    if (dirOf) out.push({ id, name, dir: dirOf, enabled: enabled[id] !== false })
135  }
136  return out
137}
138
139async function readAll($: EngineInterface) {
140  roots = await findRoots($)
141  const list: ModLogic[] = []
142  for (const root of [...roots, ...drafts]) list.push(await readMod($, root))
143  await update($, logic, () => list.sort((a, b) => a.name.localeCompare(b.name)))
144  void push($).catch(() => {})
145}
146
147// One mod's modules: those hooks.json names, and the local files they import, within its folder.
148async function readMod($: EngineInterface, root: Root): Promise<ModLogic> {
149  const hooksJson = await readJson($, `${root.dir}/hooks/hooks.json`)
150  const modules = Array.isArray(hooksJson?.modules) ? (hooksJson.modules as unknown[]).filter((m): m is string => typeof m === 'string') : []
151  if (modules.length === 0) {
152    const empty = analyze(root.id, root.name, root.enabled, {})
153    return { ...empty, error: hooksJson ? undefined : 'no hooks module: a classic plugin (skills, agents, commands)' }
154  }
155  const files: Record<string, string> = {}
156  const queue = modules.map(m => normalize(`${root.dir}/hooks/${m}`))
157  while (queue.length > 0 && Object.keys(files).length < MAX_FILES) {
158    const path = queue.shift()!
159    const rel = path.slice(root.dir.length + 1)
160    if (rel in files || !path.startsWith(`${root.dir}/`)) continue
161    const text = await readSource($, path)
162    if (text === null) continue
163    files[rel] = text
164    const here = path.slice(0, path.lastIndexOf('/'))
165    for (const spec of importsOf(text)) queue.push(normalize(`${here}/${spec}`))
166  }
167  return analyze(root.id, root.name, root.enabled, files)
168}
169
170// A module's text, trying the extensions an import may leave out.
171async function readSource($: EngineInterface, path: string): Promise<string | null> {
172  for (const candidate of /\.(tsx?|jsx?|mjs|cjs)$/.test(path) ? [path] : ['.ts', '.tsx', '.js', '.mjs', '/index.ts', '/index.tsx'].map(x => path + x)) {
173    const text = await $.fs.read(candidate).catch(() => null)
174    if (typeof text === 'string') return text
175  }
176  return null
177}
178
179// A file Claude just changed: the mod whose folder holds it is read again, soon (edits come in runs).
180async function touched($: EngineInterface, path: string) {
181  let root = [...roots, ...drafts].sort((a, b) => b.dir.length - a.dir.length).find(r => path.startsWith(`${r.dir}/`))
182  if (!root) root = (await draftFor($, path)) ?? undefined
183  if (!root) return
184  const r = root
185  timers.get(r.id)?.cancel()
186  timers.set(
187    r.id,
188    $.clock.after(DEBOUNCE_MS, () => {
189      void (async () => {
190        const fresh = await readMod($, r)
191        await update($, logic, list => [...list.filter(m => m.id !== r.id), fresh].sort((a, b) => a.name.localeCompare(b.name)))
192        await update($, changed, () => ({ mod: r.id, file: path.slice(r.dir.length + 1), at: Date.now() }))
193        await push($)
194      })().catch(() => {})
195    }),
196  )
197}
198
199// A folder up to four levels above `path` that holds .claude-plugin/plugin.json: a mod being written.
200async function draftFor($: EngineInterface, path: string): Promise<Root | null> {
201  let dir = path.slice(0, path.lastIndexOf('/'))
202  for (let i = 0; i < 4 && dir.includes('/'); i++) {
203    const manifest = await readJson($, `${dir}/.claude-plugin/plugin.json`)
204    if (manifest && typeof manifest.name === 'string') {
205      const draft: Root = { id: `draft:${manifest.name}`, name: `${manifest.name} (draft)`, dir, enabled: false }
206      drafts = [...drafts.filter(d => d.dir !== dir), draft]
207      return draft
208    }
209    dir = dir.slice(0, dir.lastIndexOf('/'))
210  }
211  return null
212}
213
214// ── The hub ───────────────────────────────────────────────────────────────
215
216type Server = { port: number; token: string; pid?: number; startedAt?: string }
217
218async function hub($: EngineInterface): Promise<Server | null> {
219  const s = await readJson($, `${await configDir($)}/mod-hub/server.json`)
220  return s && typeof s.port === 'number' && typeof s.token === 'string' ? s : null
221}
222
223// Sends the logic to a running Mod Hub, for its 2D and 3D view; nothing happens when none runs.
224async function push($: EngineInterface) {
225  const s = await hub($)
226  if (!s) return
227  const r = await $.http.fetch(`http://127.0.0.1:${s.port}/api/graph`, {
228    method: 'POST',
229    headers: { 'content-type': 'application/json', 'x-mod-hub-token': s.token },
230    body: JSON.stringify({ mods: await read($, logic), changed: await read($, changed) }),
231  }).catch(() => null)
232  sentTo = r?.ok ? `${s.pid}:${s.startedAt}` : ''
233}
234
235// Sends the graph to a hub that has not had it: one started, or restarted, since the last send.
236async function pushIfNew($: EngineInterface) {
237  const s = await hub($)
238  if (!s || `${s.pid}:${s.startedAt}` === sentTo || (await read($, logic)).length === 0) return
239  await push($)
240}
241
242async function openIn3D($: EngineInterface) {
243  const s = await hub($)
244  const ping = s ? await $.http.fetch(`http://127.0.0.1:${s.port}/api/ping`).catch(() => null) : null
245  if (!s || ping?.status !== 200) {
246    $.ui.toast('Starting the Mod Hub. Press 3D again once it opens')
247    await $.command.run({ command: 'hub' }).catch(() => $.ui.toast('The 3D view needs mod-hub: install it, then run /hub'))
248    return
249  }
250  await push($)
251  const url = `http://127.0.0.1:${s.port}/#/logic`
252  const isWindows = /^[A-Za-z]:\//.test(slash($.plugin.root))
253  await $.process.run(isWindows ? ['explorer.exe', url] : ['open', url], { timeoutMs: 10_000 }).catch(() => null)
254}
255
256// ── The pane ──────────────────────────────────────────────────────────────
257
258export function ago(at: number, now: number) {
259  const s = Math.max(0, Math.round((now - at) / 1000))
260  return s < 60 ? `${s} s ago` : s < 3600 ? `${Math.round(s / 60)} min ago` : `${Math.round(s / 3600)} h ago`
261}
262
263// The strip under the pane's title: every capability call across the mods shown, split by risk.
264export function riskStrip(mods: ModLogic[]): string {
265  const W = 860
266  const H = 54
267  const totals = RISKS.map(r => ({ ...r, n: mods.reduce((sum, m) => sum + m.calls.filter(c => c.risk === r.value).reduce((s, c) => s + c.count, 0), 0) })).filter(r => r.n > 0)
268  const all = Math.max(1, totals.reduce((s, r) => s + r.n, 0))
269  const o = [`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="Segoe UI, system-ui, sans-serif">`]
270  o.push(`<rect x="0" y="6" width="${W}" height="12" rx="6" fill="#2b2620"/>`)
271  let x = 0
272  totals.forEach((r, i) => {
273    const w = (r.n / all) * W
274    const isEdge = i === 0 || i === totals.length - 1
275    o.push(`<rect x="${x.toFixed(1)}" y="6" width="${Math.max(2, w - (i === totals.length - 1 ? 0 : 2)).toFixed(1)}" height="12" rx="${isEdge ? 6 : 1.5}" fill="${r.color}"><title>${r.label}: ${r.n} calls</title></rect>`)
276    x += w
277  })
278  let lx = 0
279  for (const r of totals.slice(0, 7)) {
280    o.push(`<circle cx="${lx + 5}" cy="40" r="4.5" fill="${r.color}"/>`)
281    o.push(`<text x="${lx + 15}" y="44.5" font-size="12.5" fill="#b9b1a2"><tspan font-weight="700" fill="#ece6da">${r.n}</tspan> ${r.label}</text>`)
282    lx += 34 + String(r.n).length * 7.5 + r.label.length * 6.1
283  }
284  o.push('</svg>')
285  return o.join('')
286}
287
288async function drawPane($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0]) {
289  const elements = $.ui.resolve(e)
290  const { Box, Text, Button } = elements
291  const Select = 'Select' in elements ? elements.Select : undefined
292  const Svg = 'Svg' in elements ? elements.Svg : undefined
293  const list = await read($, logic)
294  const last = await read($, changed)
295  const view = await read($, tab)
296  const picked = await read($, focus)
297  const shownKinds = await read($, kinds)
298  const at = await read($, page)
299  const isShared = await read($, shared)
300  const now = Date.now()
301
302  if (list.length === 0) return <Text dimColor>Reading your mods…</Text>
303  const readable = list.filter(m => !m.error)
304  const full = buildGraph(readable)
305  // One mod: everything it touches. Every mod: only where two or more meet, unless asked for all.
306  const kindsShown = filterKinds(picked ? focusGraph(full, picked) : full, shownKinds)
307  const graph = picked || !isShared ? kindsShown : sharedOnly(kindsShown)
308  const hookCount = readable.reduce((n, m) => n + m.hooks.length, 0)
309  const capCount = full.nodes.filter(n => n.kind === 'capability').length
310  const failOpen = readable.filter(isFailOpen)
311  const isFresh = last !== null && now - last.at < 30_000
312  const scope = picked ? readable.filter(m => m.id === picked) : readable
313
314  const pill = (key: string, text: string, bg: string) => (
315    <Text key={key} backgroundColor={bg} color="inverseText" weight="semibold" size="caption">
316      {` ${text} `}
317    </Text>
318  )
319  const toggleKind = (k: NodeKind) => update($, kinds, ks => (ks.includes(k) ? ks.filter(x => x !== k) : [...ks, k]))
320
321  const header = (
322    <Box flexDirection="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" columnGap={2}>
323      <Box flexDirection="column">
324        <Text size="title" weight="bold">
325          <Text color="claude">{'◇ '}</Text>
326          {'Mod Logic'}
327        </Text>
328        <Text size="footnote" dimColor>
329          {`${readable.length} mods · ${hookCount} hooks · ${capCount} capabilities${failOpen.length ? ` · ${failOpen.length} fail open` : ''}`}
330        </Text>
331      </Box>
332      <Box flexDirection="row" columnGap={1} alignItems="center">
333        <Button key="three-d" label="◈ 2D / 3D in Hub" variant="primary" onPress={() => openIn3D($)} />
334        <Button key="refresh" label="↻" plain dimColor onPress={() => readAll($)} />
335        <Button key="close" label="✕" plain dimColor role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
336      </Box>
337    </Box>
338  )
339
340  const live = (
341    <Box flexDirection="row" columnGap={1} alignItems="center">
342      {isFresh ? pill('live', '● LIVE', 'warning') : pill('live', '○ LIVE', 'inactive')}
343      <Text size="footnote" dimColor={!isFresh} wrap="truncate-end">
344        {last ? `${last.mod.replace(/@.*$/, '')} changed ${ago(last.at, now)} · ${last.file}` : 'edit any mod and its logic redraws here'}
345      </Text>
346    </Box>
347  )
348
349  const toolbar = (
350    <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1} gap={1}>
351      <Box flexDirection="row" flexWrap="wrap" columnGap={1} alignItems="center">
352        <Button key="tab-map" label="◇ Map" variant={view === 'map' ? 'primary' : undefined} plain={view === 'map' ? undefined : true} onPress={() => update($, tab, () => 'map')} />
353        <Button key="tab-breakdown" label="☰ Breakdown" variant={view === 'breakdown' ? 'primary' : undefined} plain={view === 'breakdown' ? undefined : true} onPress={() => update($, tab, () => 'breakdown')} />
354        <Text dimColor>│</Text>
355        {Select && (
356          <Select
357            key="focus"
358            label="Mod"
359            options={[{ value: '', label: 'Every mod' }, ...readable.map(m => ({ value: m.id, label: m.name }))]}
360            value={picked}
361            onSelect={v => {
362              void update($, focus, () => v)
363              void update($, page, () => 0)
364            }}
365          />
366        )}
367        {view === 'map' && !picked && (
368          <Button key="shared" label={isShared ? '◎ Shared only' : '◉ Everything'} plain={isShared ? undefined : true} variant={isShared ? 'primary' : undefined} onPress={() => update($, shared, s => !s)} />
369        )}
370      </Box>
371      {view === 'map' && (
372        <Box flexDirection="row" flexWrap="wrap" columnGap={1} alignItems="center">
373          <Text size="caption" weight="semibold" dimColor>
374            SHOW
375          </Text>
376          {KINDS.map(k => (
377            <Button key={`kind-${k.value}`} label={`${shownKinds.includes(k.value) ? '☑' : '☐'} ${k.label}`} plain dimColor={!shownKinds.includes(k.value)} onPress={() => toggleKind(k.value)} />
378          ))}
379        </Box>
380      )}
381    </Box>
382  )
383
384  const top = (
385    <>
386      {header}
387      {live}
388      {Svg && <Svg key="risk" source={riskStrip(scope)} alt="Capability calls by risk" isInteractive />}
389      {toolbar}
390    </>
391  )
392
393  if (view === 'map') {
394    return (
395      <Box flexDirection="column" gap={1}>
396        {top}
397        {Svg ? (
398          <Box flexDirection="column" borderStyle="round" borderColor="inactive">
399            <Svg key="map" source={graphSvg(graph, { width: 900, height: 560, focus: picked || undefined, changed: isFresh ? last!.mod : undefined })} alt={`Graph of ${graph.nodes.length} parts across ${readable.length} mods`} isInteractive />
400          </Box>
401        ) : (
402          <Text dimColor>This surface draws no pictures. ☰ Breakdown has the same in words, and ◈ opens the 3D view.</Text>
403        )}
404        <Text size="caption" dimColor wrap="wrap">
405          {'● mods on the ring  ◯ events they hook  ■ capabilities, coloured by risk  ·  hover any shape for who uses it'}
406        </Text>
407      </Box>
408    )
409  }
410
411  const mods = picked ? readable.filter(m => m.id === picked) : list
412  const pages = Math.max(1, Math.ceil(mods.length / PER_PAGE))
413  const p = Math.min(at, pages - 1)
414  return (
415    <Box flexDirection="column" gap={1}>
416      {top}
417      {mods.slice(p * PER_PAGE, (p + 1) * PER_PAGE).map(m => {
418        const isLive = isFresh && last!.mod === m.id
419        return (
420          <Box
421            key={`logic-${m.id}`}
422            flexDirection="column"
423            borderStyle="round"
424            borderColor={isLive ? 'warning' : isFailOpen(m) ? 'error' : 'inactive'}
425            hover={{ borderColor: 'claude' }}
426            paddingX={1}
427          >
428            <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={1}>
429              <Text wrap="truncate-end">
430                <Text size="heading" weight="semibold" dimColor={!m.enabled}>
431                  {m.name}
432                </Text>
433                <Text size="footnote" mono dimColor>{m.error ? '' : `  ${m.lines} lines · ${m.files.length} file${m.files.length === 1 ? '' : 's'}`}</Text>
434              </Text>
435              <Box flexDirection="row" columnGap={1}>
436                {isLive && pill(`chg-${m.id}`, 'JUST CHANGED', 'warning')}
437                {!m.enabled && pill(`off-${m.id}`, 'OFF', 'inactive')}
438                {m.guards > 0 && pill(`guard-${m.id}`, 'FAILS CLOSED', 'success')}
439              </Box>
440            </Box>
441            {m.error ? (
442              <Text size="footnote" dimColor italic>
443                {m.error}
444              </Text>
445            ) : (
446              <>
447                <Text size="footnote" wrap="wrap">
448                  <Text color="#7fb8a4" weight="semibold">{'HOOKS  '}</Text>
449                  <Text dimColor>{m.hooks.length ? m.hooks.map(h => `${h.event}${h.matcher ? ` · ${h.matcher}` : ''}${h.count > 1 ? ` ×${h.count}` : ''}`).join('   ') : 'none'}</Text>
450                </Text>
451                <Text size="footnote" wrap="wrap">
452                  <Text color="#9aa7c7" weight="semibold">{'CALLS  '}</Text>
453                  {m.calls.length === 0 && <Text dimColor>none</Text>}
454                  {m.calls.map((c, i) => (
455                    <Text key={`call-${c.name}`} color={RISKS.find(r => r.value === c.risk)?.color} mono>
456                      {`${i ? '   ' : ''}$.${c.name}${c.count > 1 ? ` ×${c.count}` : ''}`}
457                    </Text>
458                  ))}
459                </Text>
460                {(m.commands.length > 0 || m.tools.length > 0 || m.state.length > 0) && (
461                  <Text size="footnote" dimColor wrap="wrap">
462                    {[m.commands.length ? `COMMANDS ${m.commands.map(c => `/${c}`).join(' ')}` : '', m.tools.length ? `TOOLS ${m.tools.join(' ')}` : '', m.state.length ? `STATE ${m.state.join(' ')}` : ''].filter(Boolean).join('     ')}
463                  </Text>
464                )}
465                {isFailOpen(m) && (
466                  <Box borderStyle="round" borderColor="error" paddingX={1}>
467                    <Text size="footnote" color="error" wrap="wrap">
468                      ⚠ Fails open: it hooks tool calls with no .catch handler, so if it throws, the call goes ahead unchecked.
469                    </Text>
470                  </Box>
471                )}
472              </>
473            )}
474          </Box>
475        )
476      })}
477      {pages > 1 && (
478        <Box flexDirection="row" justifyContent="space-between" alignItems="center">
479          <Button key="prev" label="‹ Prev" plain dimColor={p === 0} onPress={() => update($, page, n => Math.max(0, n - 1))} />
480          <Text size="footnote" dimColor tabular>{`page ${p + 1} of ${pages}`}</Text>
481          <Button key="next" label="Next ›" plain dimColor={p >= pages - 1} onPress={() => update($, page, n => Math.min(pages - 1, n + 1))} />
482        </Box>
483      )}
484    </Box>
485  )
486}
487
488// A mod that hooks tool calls, does more than draw, and has no .catch handler: a throw lets the call through.
489export function isFailOpen(m: ModLogic): boolean {
490  return m.hooks.some(h => h.event === 'tool.call' || h.event === 'tool.check') && m.guards === 0 && !m.calls.every(c => c.risk === 'draws' || c.risk === 'state')
491}
492
shared/logic.ts 289 lines
1// What a mod does, read off its source: the events it hooks, the $ capabilities it calls,
2// and what it registers. Then one graph of every mod, laid out in 2D or 3D. Pure: no
3// `claude-code` import, so the mod's pane and the hub's web view (which imports this file)
4// read mods and draw the graph exactly alike.
5
6export type Risk = 'runs' | 'network' | 'writes' | 'reads' | 'drives' | 'draws' | 'state' | 'time' | 'other'
7
8export type ModLogic = {
9  id: string // name@marketplace, or the folder for a mod that is not installed
10  name: string
11  enabled: boolean
12  files: string[] // the module files read, relative to the mod's folder
13  hooks: { event: string; matcher: string; count: number }[] // "tool.call" + "Bash"
14  calls: { name: string; risk: Risk; count: number }[] // "process.run"
15  commands: string[]
16  tools: string[]
17  state: string[]
18  guards: number // hooks registered with a .catch handler
19  lines: number
20  error?: string // why it could not be read
21}
22
23export type NodeKind = 'mod' | 'event' | 'capability' | 'command' | 'tool' | 'state'
24export type GraphNode = { id: string; kind: NodeKind; label: string; risk?: Risk; weight: number; mods: string[] }
25export type GraphEdge = { from: string; to: string; kind: 'hooks' | 'calls' | 'registers' | 'keeps'; count: number }
26export type Graph = { nodes: GraphNode[]; edges: GraphEdge[] }
27
28export const RISKS: { value: Risk; label: string; color: string }[] = [
29  { value: 'runs', label: 'runs programs', color: '#f08a5d' },
30  { value: 'network', label: 'uses the network', color: '#e5566b' },
31  { value: 'writes', label: 'writes files', color: '#e8c15a' },
32  { value: 'drives', label: 'drives Claude', color: '#d98be0' },
33  { value: 'reads', label: 'reads files', color: '#6cc5d9' },
34  { value: 'draws', label: 'draws UI', color: '#8fd48a' },
35  { value: 'state', label: 'keeps state', color: '#9aa7c7' },
36  { value: 'time', label: 'uses timers', color: '#b9ad94' },
37  { value: 'other', label: 'other', color: '#8a8274' },
38]
39export const KIND_COLOR: Record<NodeKind, string> = { mod: '#e3a857', event: '#7fb8a4', capability: '#9aa7c7', command: '#f0c27a', tool: '#d98be0', state: '#6f86b8' }
40
41export function riskOf(call: string): Risk {
42  const [noun, verb = ''] = call.split('.')
43  if (noun === 'process') return 'runs'
44  if (noun === 'http') return 'network'
45  if (noun === 'fs') return verb === 'write' ? 'writes' : 'reads'
46  if (noun === 'command' && verb === 'run') return 'drives'
47  if (noun === 'prompt' || noun === 'agent' || noun === 'model' || (noun === 'tool' && verb === 'call')) return 'drives'
48  if (noun === 'ui' || noun === 'audio') return 'draws'
49  if (noun === 'store' || noun === 'state' || noun === 'settings' || noun === 'config' || noun === 'env' || noun === 'session') return 'state'
50  if (noun === 'clock') return 'time'
51  return 'other'
52}
53
54// Strips comments and the insides of strings that are not quoted names, so a word in a
55// comment ("we never call $.http.fetch") is not read as a call.
56function code(text: string): string {
57  return text.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:\\])\/\/.*$/gm, '$1')
58}
59
60const count = <T extends string>(list: T[]) => {
61  const m = new Map<T, number>()
62  for (const x of list) m.set(x, (m.get(x) ?? 0) + 1)
63  return m
64}
65
66// The relative modules a file imports (./x, ../y), for following a hooks module into its helpers.
67export function importsOf(text: string): string[] {
68  const out: string[] = []
69  for (const m of code(text).matchAll(/\b(?:import|export)\s[^'"]*?from\s*['"](\.{1,2}\/[^'"]+)['"]/g)) out.push(m[1]!)
70  for (const m of code(text).matchAll(/\bimport\s*\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/g)) out.push(m[1]!)
71  return out
72}
73
74// One mod's logic, from its module files' text.
75export function analyze(id: string, name: string, enabled: boolean, files: Record<string, string>): ModLogic {
76  const all = Object.values(files).map(code).join('\n')
77  const hooks: { event: string; matcher: string }[] = []
78  for (const m of all.matchAll(/\bon\(\s*['"]([\w.*:-]+)['"]\s*(?:,\s*\{([^{}]*)\})?/g)) {
79    const matcher = (m[2] ?? '').match(/\b(?:tool|component|command|requestId|name|surface)\s*:\s*['"]([^'"]+)['"]/)?.[1] ?? ''
80    hooks.push({ event: m[1]!, matcher })
81  }
82  const calls = [...all.matchAll(/\$\.(\w+)\.(\w+)\s*\(/g)].map(m => `${m[1]}.${m[2]}`)
83  const names = (re: RegExp) => [...new Set([...all.matchAll(re)].map(m => m[1]!))].sort()
84  const hookCounts = count(hooks.map(h => `${h.event}\u0000${h.matcher}`))
85  return {
86    id,
87    name,
88    enabled,
89    files: Object.keys(files).sort(),
90    hooks: [...hookCounts].map(([k, n]) => ({ event: k.split('\u0000')[0]!, matcher: k.split('\u0000')[1]!, count: n })).sort((a, b) => a.event.localeCompare(b.event) || a.matcher.localeCompare(b.matcher)),
91    calls: [...count(calls)].map(([c, n]) => ({ name: c, risk: riskOf(c), count: n })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)),
92    commands: names(/command\.register\(\s*\{[^}]*?\bname\s*:\s*['"]([\w:.-]+)['"]/g),
93    tools: names(/tool\.register\(\s*\{[^}]*?\bname\s*:\s*['"]([\w:.-]+)['"]/g),
94    state: names(/atom\(\s*\{[^}]*?\bkey\s*:\s*['"]([\w-]+)['"]/g),
95    guards: (all.match(/\)\s*\.catch\(\s*[A-Za-z_$][\w$]*\s*\)/g) ?? []).length + (all.match(/\)\s*\.catch\(\s*\(\s*\$/g) ?? []).length,
96    lines: Object.values(files).reduce((n, t) => n + t.split('\n').length, 0),
97  }
98}
99
100const eventId = (event: string, matcher: string) => `event:${event}${matcher ? `:${matcher}` : ''}`
101
102// Every mod joined to what it hooks, calls and registers. Events and capabilities are shared
103// nodes, so mods that hook the same event or call the same capability meet there.
104export function buildGraph(mods: ModLogic[]): Graph {
105  const nodes = new Map<string, GraphNode>()
106  const edges: GraphEdge[] = []
107  const node = (id: string, kind: NodeKind, label: string, mod: string, weight: number, risk?: Risk) => {
108    const n = nodes.get(id) ?? { id, kind, label, weight: 0, mods: [], ...(risk ? { risk } : {}) }
109    n.weight += weight
110    if (!n.mods.includes(mod)) n.mods.push(mod)
111    nodes.set(id, n)
112    return id
113  }
114  for (const m of mods) {
115    if (m.error) continue
116    const modId = node(`mod:${m.id}`, 'mod', m.name, m.id, m.lines)
117    for (const h of m.hooks) edges.push({ from: modId, to: node(eventId(h.event, h.matcher), 'event', h.matcher ? `${h.event} · ${h.matcher}` : h.event, m.id, h.count), kind: 'hooks', count: h.count })
118    for (const c of m.calls) edges.push({ from: modId, to: node(`cap:${c.name}`, 'capability', `$.${c.name}`, m.id, c.count, c.risk), kind: 'calls', count: c.count })
119    for (const c of m.commands) edges.push({ from: modId, to: node(`cmd:${c}`, 'command', `/${c}`, m.id, 1), kind: 'registers', count: 1 })
120    for (const t of m.tools) edges.push({ from: modId, to: node(`tool:${t}`, 'tool', `tool ${t}`, m.id, 1), kind: 'registers', count: 1 })
121    for (const s of m.state) edges.push({ from: modId, to: node(`state:${m.id}:${s}`, 'state', s, m.id, 1), kind: 'keeps', count: 1 })
122  }
123  return { nodes: [...nodes.values()], edges }
124}
125
126// The part of the graph one mod touches: the mod, its neighbours, and the other mods that share them.
127export function focusGraph(g: Graph, modId: string): Graph {
128  const center = `mod:${modId}`
129  const near = new Set([center, ...g.edges.filter(e => e.from === center).map(e => e.to)])
130  const sharing = new Set(g.edges.filter(e => near.has(e.to) && e.from !== center).map(e => e.from))
131  const keep = new Set([...near, ...sharing])
132  return { nodes: g.nodes.filter(n => keep.has(n.id)), edges: g.edges.filter(e => keep.has(e.from) && keep.has(e.to) && (e.from === center || near.has(e.to))) }
133}
134
135// Only the kinds asked for (mods always stay), and edges between what is left.
136export function filterKinds(g: Graph, kinds: readonly NodeKind[]): Graph {
137  const keep = new Set(g.nodes.filter(n => n.kind === 'mod' || kinds.includes(n.kind)).map(n => n.id))
138  return { nodes: g.nodes.filter(n => keep.has(n.id)), edges: g.edges.filter(e => keep.has(e.from) && keep.has(e.to)) }
139}
140
141// A seeded random, so one graph lays out the same way every time and a live update moves little.
142function seeded(text: string) {
143  let h = 2166136261
144  for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619)
145  return () => {
146    h = Math.imul(h ^ (h >>> 15), 2246822507)
147    h = Math.imul(h ^ (h >>> 13), 3266489909)
148    return ((h ^= h >>> 16) >>> 0) / 4294967296
149  }
150}
151
152export type Placed = Record<string, [number, number, number]>
153
154// Mods sit fixed on a ring (2D) or a sphere (3D), in name order; everything else is laid out
155// inside by force: pulled toward the mods that use it, pushed apart from its neighbours. So a
156// part many mods share sits near the middle, and one mod's own parts sit by that mod.
157// Positions fall within -1..1.
158export function layout(g: Graph, dims: 2 | 3, iterations = 220): Placed {
159  const pos: Placed = {}
160  const mods = g.nodes.filter(n => n.kind === 'mod').sort((a, b) => a.label.localeCompare(b.label))
161  mods.forEach((n, i) => {
162    if (dims === 2) {
163      const a = (i / Math.max(1, mods.length)) * Math.PI * 2 - Math.PI / 2
164      pos[n.id] = [Math.cos(a), Math.sin(a), 0]
165    } else {
166      // A Fibonacci sphere: even spacing for any count.
167      const y = mods.length === 1 ? 0 : 1 - (i / (mods.length - 1)) * 2
168      const r = Math.sqrt(1 - y * y)
169      const a = i * Math.PI * (3 - Math.sqrt(5))
170      pos[n.id] = [Math.cos(a) * r, y, Math.sin(a) * r]
171    }
172  })
173  const free = g.nodes.filter(n => n.kind !== 'mod').map(n => n.id)
174  const fixed = new Set(mods.map(m => m.id))
175  const neighbours = new Map<string, string[]>()
176  for (const e of g.edges) {
177    neighbours.set(e.to, [...(neighbours.get(e.to) ?? []), e.from])
178    neighbours.set(e.from, [...(neighbours.get(e.from) ?? []), e.to])
179  }
180  // Start each part at the middle of its mods, nudged by a seeded jitter so two never coincide.
181  for (const id of free) {
182    const r = seeded(id)
183    const near = (neighbours.get(id) ?? []).filter(x => fixed.has(x)).map(x => pos[x]!)
184    const c = near.length ? [0, 1, 2].map(k => near.reduce((s, p) => s + p[k]!, 0) / near.length) : [0, 0, 0]
185    pos[id] = [c[0]! * 0.7 + (r() - 0.5) * 0.2, c[1]! * 0.7 + (r() - 0.5) * 0.2, dims === 3 ? c[2]! * 0.7 + (r() - 0.5) * 0.2 : 0]
186  }
187  const spacing = (dims === 3 ? 0.5 : 0.44) / Math.sqrt(Math.max(1, free.length / 24))
188  for (let it = 0; it < iterations; it++) {
189    const step = 0.06 * (1 - it / iterations) + 0.003
190    for (const id of free) {
191      const p = pos[id]!
192      const f = [0, 0, 0]
193      // Pull toward the middle of the mods it belongs to, averaged so a part every mod shares
194      // is pulled no harder than one two mods share: then spacing, not popularity, decides.
195      const near = (neighbours.get(id) ?? []).map(x => pos[x]).filter((q): q is [number, number, number] => q !== undefined)
196      if (near.length) for (let k = 0; k < dims; k++) f[k]! += (near.reduce((s, q) => s + q[k]!, 0) / near.length - p[k]!) * 0.5
197      // Push away from other parts closer than the spacing.
198      for (const other of free) {
199        if (other === id) continue
200        const q = pos[other]!
201        const d = [p[0] - q[0], p[1] - q[1], p[2] - q[2]]
202        const dist = Math.max(0.005, Math.hypot(d[0]!, d[1]!, d[2]!))
203        if (dist < spacing) for (let k = 0; k < dims; k++) f[k]! += (d[k]! / dist) * (spacing - dist) * 1.4
204      }
205      const len = Math.hypot(f[0]!, f[1]!, f[2]!)
206      if (len > 0) for (let k = 0; k < dims; k++) p[k]! += (f[k]! / len) * Math.min(len, step)
207      // Inside the ring or sphere of mods.
208      const out = Math.hypot(p[0], p[1], p[2])
209      if (out > 0.84) for (let k = 0; k < 3; k++) p[k]! *= 0.84 / out
210    }
211  }
212  return pos
213}
214
215// Mods, and only the parts at least `min` mods share: where mods meet. A focused view keeps everything.
216export function sharedOnly(g: Graph, min = 2): Graph {
217  const keep = new Set(g.nodes.filter(n => n.kind === 'mod' || n.mods.length >= min).map(n => n.id))
218  return { nodes: g.nodes.filter(n => keep.has(n.id)), edges: g.edges.filter(e => keep.has(e.from) && keep.has(e.to)) }
219}
220
221export function nodeColor(n: GraphNode): string {
222  return n.kind === 'capability' ? (RISKS.find(r => r.value === n.risk)?.color ?? KIND_COLOR.capability) : KIND_COLOR[n.kind]
223}
224
225export function nodeSize(n: GraphNode): number {
226  return n.kind === 'mod' ? 7 + Math.min(6, Math.sqrt(n.weight) / 5) : 3.5 + Math.min(7, n.mods.length * 0.9)
227}
228
229const esc = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!)
230
231// The desktop's Svg element takes at most 131,072 characters; a drawing past this drops part labels.
232const SVG_BUDGET = 120_000
233
234// The graph as an SVG document for the pane's Svg element: hover a shape for its <title>.
235export function graphSvg(g: Graph, opts: { width: number; height: number; focus?: string; changed?: string; labels?: 'all' | 'mods' } = { width: 900, height: 560 }): string {
236  const { width: W, height: H } = opts
237  const pos = layout(g, 2)
238  const pad = 64
239  const scale = Math.min(W, H) / 2 - pad
240  const xy = (id: string) => {
241    const p = pos[id] ?? [0, 0, 0]
242    return [W / 2 + p[0] * scale * (W > H ? 1.35 : 1), H / 2 + p[1] * scale] as const
243  }
244  const labels = opts.labels ?? 'all'
245  // Only the most shared parts get a label, so the middle stays readable; hover gives the rest.
246  const named = new Set(g.nodes.filter(n => n.kind !== 'mod' && n.mods.length >= 3).sort((a, b) => b.mods.length - a.mods.length).slice(0, 14).map(n => n.id))
247  const o: string[] = [
248    `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="Segoe UI, system-ui, sans-serif">`,
249    `<rect width="${W}" height="${H}" rx="14" fill="#1b1814"/>`,
250  ]
251  if (g.nodes.length === 0) {
252    o.push(`<text x="${W / 2}" y="${H / 2}" text-anchor="middle" font-size="15" fill="#8a8274">No mod logic to draw yet</text></svg>`)
253    return o.join('')
254  }
255  for (const e of g.edges) {
256    const [x1, y1] = xy(e.from)
257    const [x2, y2] = xy(e.to)
258    const hot = opts.focus && e.from === `mod:${opts.focus}`
259    o.push(`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${hot ? '#e3a857' : '#544c40'}" stroke-opacity="${hot ? 0.85 : 0.4}" stroke-width="${hot ? 1.6 : 1}"/>`)
260  }
261  for (const n of g.nodes) {
262    const [x, y] = xy(n.id)
263    const r = nodeSize(n)
264    const color = nodeColor(n)
265    const isChanged = opts.changed && n.id === `mod:${opts.changed}`
266    const who = n.kind === 'mod' ? '' : `\nused by ${n.mods.length} mod${n.mods.length === 1 ? '' : 's'}: ${n.mods.map(m => m.replace(/@.*$/, '')).join(', ')}`
267    o.push(`<g><title>${esc(`${n.label} (${n.kind}${n.risk ? `, ${n.risk}` : ''})${who}`)}</title>`)
268    if (isChanged) o.push(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${(r + 6).toFixed(1)}" fill="none" stroke="#e8d27a" stroke-width="2" stroke-dasharray="3 3"/>`)
269    o.push(
270      n.kind === 'mod'
271        ? `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r.toFixed(1)}" fill="${color}" stroke="#1b1814" stroke-width="2"/>`
272        : n.kind === 'event'
273          ? `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r.toFixed(1)}" fill="none" stroke="${color}" stroke-width="2"/>`
274          : `<rect x="${(x - r).toFixed(1)}" y="${(y - r).toFixed(1)}" width="${(2 * r).toFixed(1)}" height="${(2 * r).toFixed(1)}" rx="2" fill="${color}" fill-opacity="0.9"/>`,
275    )
276    const isLabelled = n.kind === 'mod' || (labels === 'all' && named.has(n.id))
277    if (isLabelled) {
278      const text = n.label.length > 26 ? `${n.label.slice(0, 25)}…` : n.label
279      const below = n.kind === 'mod' ? y > H / 2 : true // ring labels face outward
280      const ly = n.kind === 'mod' ? (below ? y + r + 14 : y - r - 7) : y + r + 12
281      o.push(`<text x="${x.toFixed(1)}" y="${ly.toFixed(1)}" text-anchor="middle" font-size="${n.kind === 'mod' ? 12 : 10.5}" font-weight="${n.kind === 'mod' ? 600 : 400}" fill="${n.kind === 'mod' ? '#ece6da' : '#c9c0b0'}" stroke="#1b1814" stroke-width="3" paint-order="stroke">${esc(text)}</text>`)
282    }
283    o.push('</g>')
284  }
285  o.push('</svg>')
286  const out = o.join('')
287  return out.length > SVG_BUDGET && labels === 'all' ? graphSvg(g, { ...opts, labels: 'mods' }) : out
288}
289
types/index.d.ts 36 lines
1// The shapes shared/logic.ts works with, restated: a state contract holds no imports.
2export type Risk = 'runs' | 'network' | 'writes' | 'reads' | 'drives' | 'draws' | 'state' | 'time' | 'other'
3export type NodeKind = 'mod' | 'event' | 'capability' | 'command' | 'tool' | 'state'
4
5export type ModLogic = {
6  id: string
7  name: string
8  enabled: boolean
9  files: string[]
10  hooks: { event: string; matcher: string; count: number }[]
11  calls: { name: string; risk: Risk; count: number }[]
12  commands: string[]
13  tools: string[]
14  state: string[]
15  guards: number
16  lines: number
17  error?: string
18}
19
20// The last edit that changed a mod's logic: which mod, which file, when.
21export type LogicChange = { mod: string; file: string; at: number }
22
23declare module 'claude-code' {
24  interface PluginState {
25    'mod-logic': {
26      logic: ModLogic[]
27      changed: LogicChange | null
28      tab: 'map' | 'breakdown'
29      focus: string // a mod id, '' for every mod
30      kinds: NodeKind[] // which kinds of node the map shows besides mods
31      shared: boolean // the map shows only parts two or more mods share
32      page: number
33    }
34  }
35}
36