A pane listing the network activity of the session: web fetches, searches, MCP calls and network Bash commands

A pane listing the network activity of the session, to see what Claude reached out to.
<status> <kind> <target> · <host> with status ✓, ✗ or denied. Subagent calls are marked ↳.web (WebFetch URL), search (WebSearch query), mcp (any mcp__server__tool call; the "host" is the server name), bash.curl, wget, http, xh, ssh, scp, rsync (remote only), gh, git push|pull|fetch|clone, pip install, brew install|upgrade, xcrun notarytool, and npm/pnpm/yarn install|i|add|publish.12 requests · 4 hosts.| Command | Effect |
|---|---|
/network-log | Open the pane |
/network-log close | Close the pane |
/network-log clear | Empty the log |
claude --plugin-dir /path/to/ModsTools/mods/network-log
claude plugin validate mods/network-log
claude plugin test mods/network-log # 9 testshooks/register.tsx 67 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { NetEntry } from '../types'
5import { classifyTool, header, push, row, statusOf } from './classify'
6
7const PANE = 'network-log'
8const TITLE = 'Network'
9const entries = atom({ plugin: 'network-log', key: 'entries' } as const, [])
10
11export const register: Register = on => {
12 on('session.start', async ($, e, next) => {
13 await $.command.register({
14 name: 'network-log',
15 description: 'Show the network activity of this session (open | close | clear)',
16 })
17
18 return next(e)
19 })
20
21 on('command.run', { command: 'network-log' }, async ($, e) => {
22 const arg = e.args.trim()
23 if (arg === 'close' || arg === 'off') {
24 await $.ui.close({ id: PANE })
25 return { text: 'Network pane closed.' }
26 }
27 if (arg === 'clear') {
28 await update($, entries, () => [])
29 return { text: 'Network log cleared.' }
30 }
31 await $.ui.open({ id: PANE, title: TITLE })
32 const list: NetEntry[] = await read($, entries)
33
34 return { text: `Network pane opened (${header(list)}).` }
35 })
36
37 on('tool.call', async ($, e, next) => {
38 const input = e as unknown as { tool: string; agentId?: string } & Record<string, unknown>
39 const hit = classifyTool(input.tool, input)
40 if (hit === null) return next(e)
41 const ran = await next(e)
42 const entry: NetEntry = { ...hit, status: statusOf(ran), sub: input.agentId !== undefined }
43 await update($, entries, list => push(list, entry))
44
45 return ran
46 })
47
48 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
49 const { Box, Text } = $.ui.resolve(e)
50 const list: NetEntry[] = await read($, entries)
51 const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
52
53 return (
54 <Box flexDirection="column">
55 <Text bold>{header(list)}</Text>
56 {list.length === 0 && <Text dimColor>No network activity yet.</Text>}
57 {list.slice(0, room).map(entry => (
58 <Text>
59 {entry.sub && <Text dimColor>↳ </Text>}
60 {row(entry, e.props.bodyColumns)}
61 </Text>
62 ))}
63 </Box>
64 )
65 })
66}
67hooks/classify.ts 104 lines1import type { NetEntry } from '../types'
2
3export const CAP = 100
4
5export type Hit = { kind: NetEntry['kind']; target: string; host: string }
6
7const URL_HOST = /\b[a-z][a-z0-9+.-]*:\/\/(?:[^/@\s'"]*@)?([^/:?#\s'"]+)/i
8const SCP_HOST = /(?:^|[\s'"])(?:[\w.-]+@)?([\w.-]+\.[\w.-]+|[\w-]+):(?!\/\/)[^\s]/
9
10const NPM_LIKE = new Set(['npm', 'pnpm', 'yarn'])
11const WRAPPERS = new Set(['sudo', 'rtk', 'time', 'command', 'env'])
12
13export const hostOf = (text: string): string => URL_HOST.exec(text)?.[1]?.toLowerCase() ?? ''
14
15function remoteHost(text: string): string {
16 const url = hostOf(text)
17 return url !== '' ? url : (SCP_HOST.exec(text)?.[1]?.toLowerCase() ?? '')
18}
19
20function words(segment: string): string[] {
21 const list = segment.trim().split(/\s+/)
22 while (list.length > 0 && (/^[A-Za-z_]\w*=/.test(list[0]!) || WRAPPERS.has(list[0]!))) list.shift()
23 return list
24}
25
26/** The network-touching command of one simple shell segment, or null. */
27function segmentHost(segment: string): string | null {
28 const [cmd, ...rest] = words(segment)
29 if (cmd === undefined) return null
30 const sub = rest.find(word => !word.startsWith('-'))
31 const joined = rest.join(' ')
32 switch (cmd) {
33 case 'curl':
34 case 'wget':
35 case 'http':
36 case 'xh':
37 return hostOf(joined)
38 case 'ssh':
39 return sub?.replace(/^.*@/, '').toLowerCase() ?? ''
40 case 'scp':
41 return remoteHost(joined)
42 case 'rsync':
43 return /(^|\s)(?:[\w.-]+@)?[\w.-]+:(?!\/\/)\S|:\/\//.test(joined) ? remoteHost(joined) : null
44 case 'gh':
45 return 'github.com'
46 case 'git':
47 return sub !== undefined && ['push', 'pull', 'fetch', 'clone'].includes(sub) ? remoteHost(joined) : null
48 case 'pip':
49 case 'pip3':
50 return sub === 'install' ? 'pypi.org' : null
51 case 'brew':
52 return sub === 'install' || sub === 'upgrade' ? 'brew' : null
53 case 'xcrun':
54 return sub === 'notarytool' ? 'apple.com' : null
55 default:
56 return NPM_LIKE.has(cmd) && sub !== undefined && ['install', 'i', 'add', 'publish'].includes(sub)
57 ? 'registry.npmjs.org'
58 : null
59 }
60}
61
62/** The first network segment of a Bash command (split on && || ; | and newlines). */
63export function classifyBash(command: string): Hit | null {
64 for (const segment of command.split(/&&|\|\||[;|\n]/)) {
65 const host = segmentHost(segment)
66 if (host !== null) return { kind: 'bash', target: segment.trim().replace(/\s+/g, ' '), host }
67 }
68 return null
69}
70
71export function classifyTool(tool: string, input: Record<string, unknown>): Hit | null {
72 if (tool === 'WebFetch') {
73 const url = String(input.url ?? '')
74 return { kind: 'web', target: url, host: hostOf(url) }
75 }
76 if (tool === 'WebSearch') return { kind: 'search', target: String(input.query ?? ''), host: '' }
77 if (tool === 'Bash') return classifyBash(String(input.command ?? ''))
78 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
79 return mcp ? { kind: 'mcp', target: `${mcp[1]} › ${mcp[2]}`, host: mcp[1]! } : null
80}
81
82export function statusOf(ran: { deny?: string; isError?: boolean }): string {
83 if (ran.deny !== undefined) return 'denied'
84 return ran.isError === true ? '✗' : '✓'
85}
86
87export const push = (list: NetEntry[], entry: NetEntry) => [entry, ...list].slice(0, CAP)
88
89export function header(list: NetEntry[]): string {
90 const hosts = new Set(list.map(one => one.host).filter(host => host !== ''))
91 return `${list.length} request${list.length === 1 ? '' : 's'} · ${hosts.size} host${hosts.size === 1 ? '' : 's'}`
92}
93
94/** "<status> <kind> <target> · <host>", cut to `columns` (the dim "↳ " marker is drawn apart). */
95export function row(entry: NetEntry, columns: number): string {
96 const head = `${entry.status} ${entry.kind} `
97 const tail = entry.host !== '' && !entry.target.includes(entry.host) ? ` · ${entry.host}` : ''
98 const room = Math.max(8, columns - head.length - tail.length - (entry.sub ? 2 : 0))
99 const target = entry.target.split('\n')[0] ?? ''
100 const shown = target.length > room ? target.slice(0, room - 1) + '…' : target
101
102 return head + shown + tail
103}
104types/index.d.ts 9 lines1export type NetKind = 'web' | 'search' | 'mcp' | 'bash'
2export type NetEntry = { kind: NetKind; target: string; host: string; status: string; sub: boolean }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'network-log': { entries: NetEntry[] }
7 }
8}
9