/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…

<img src=".claude-plugin/icon.png" width="72" align="right" alt="">
/mod-logic reads every installed mod's source and draws how it works:

.claude-plugin/ folder are picked up too.#/logic), with filters by kind and a focus on one mod..catch.The analyzer, graph and layout live in shared/logic.ts, so the pane and the web app use the same code.
No settings.
hooks/register.tsx 492 lines1// 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}
492shared/logic.ts 289 lines1// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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}
289types/index.d.ts 36 lines1// 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