SLOPSHOPPER

network-log

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

newpaneguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · network-log
│ ┃ Network ✕ › fix the failing auth test and add an audit log call │ ┃ 1 request · 0 hosts │ ┃ ✓ bash git push --force origin main ⏺ 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 │ │ › /network-log │ ⎿ network-log: Network pane opened (1 request · 0 hosts). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Network
1 request · 0 hosts ✓ bash git push --force origin main
README

network-log

A pane listing the network activity of the session, to see what Claude reached out to.

What it does

  • Logs, newest first (100 kept), each entry as <status> <kind> <target> · <host> with status ✓, ✗ or denied. Subagent calls are marked ↳.
  • Kinds: web (WebFetch URL), search (WebSearch query), mcp (any mcp__server__tool call; the "host" is the server name), bash.
  • Bash lines are classified by their first network segment: 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.
  • Pane header: 12 requests · 4 hosts.

Commands

CommandEffect
/network-logOpen the pane
/network-log closeClose the pane
/network-log clearEmpty the log

Install

claude --plugin-dir /path/to/ModsTools/mods/network-log

Limits

  • One entry per Bash command, from its first network segment only; scripts and subshell contents are not seen.
  • Records the call, not the traffic: it does not capture what a tool does on the network beyond what the command line shows.
  • Kept in memory for the session.

Develop

claude plugin validate mods/network-log
claude plugin test mods/network-log   # 9 tests
Source 3 files
hooks/register.tsx 67 lines
1import { 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}
67
hooks/classify.ts 104 lines
1import 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}
104
types/index.d.ts 9 lines
1export 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