Pane listing every network connection this session made, traced back to the prompt and command that caused it

A Claude Code mod that adds a Network connections pane: every network connection a session makes, traced back to the prompt and the command that caused it.

| Kind | Source | Evidence |
|---|---|---|
MODEL | each Messages API request (turn step): model, tokens, time to first chunk | observed |
WEB | WebFetch / WebSearch: status, size, the tool's output, and on request the page's raw body | observed |
SHELL | Bash / PowerShell commands: hosts read off the command text (curl, git push, pip install, …) | ~ inferred, ? uncertain (scripts, interpreters) |
SHELL ↳#n | Windows: connections the processes of shell call #n actually opened, seen in the TCP table | observed |
MCP / SVC | MCP tool calls, claude.ai connectors and services | observed |
SHARE | file access on UNC paths, mapped network drives and network mounts | observed / inferred |
Local tool calls (Read, Edit, …) are kept too, so each prompt's calls are complete, but hidden by default.
/net opens the pane, also while a turn is running./net clear empties the log. /net drives (or /net mounts) lists network drives.↑/↓ then select a connection in the list, Enter opens its details, and b goes back with that connection still selected. Esc returns to the prompt.l list, g by prompt, h by host, z show or hide local calls. n/p page to older/newer.a all, m model, w web, s shell, x mcp, v service, f share, k local. u clears a prompt or host you drilled into.p/n newer/older, o all from its prompt, t all to its host, c copy.w tool output, r raw page (fetched again by the pane, listed as its own connection), f fetch it again, j/k parts of a long body.u jumps to the call that caused it; on a call, 1–9 jump to the connections seen under it.The engine reports a shell call, not what its processes connect to. While shell calls run, the mod runs a small PowerShell loop (hooks/poller.ts) that reads the TCP table and the process list through the Win32 API every 25 ms and reports connections of processes Claude Code started. It stops 30 s after the last shell call.
Here python check_sites.py shows no host in its command line, so the call itself is only ? uncertain; the poller saw the three connections the script made:

Limits: a connection that opens and closes between two looks is missed; a process whose parent already exited (a daemonized child) cannot be traced to Claude Code; with parallel shell calls the call a connection is attributed to may be off.
Mods only see what WebFetch hands back: its processed output. r fetches the page again and shows the raw body, in parts for long pages; w switches back to the tool output.

By prompt (g) and by host (h). The by-host view also lists hosts by set: i the hosts this session reached, e only the new ones, y all known hosts, including those only earlier sessions used (dim, marked earlier).

The by-host view with the i set (this session) selected; e and y switch the set.

With the option flagNewHosts (on by default; a row in the config menu), a host no earlier session connected to is marked ★ in the list, ★ NEW in the by-host view, ★ NEW HOST in its details, and counted in the header. Only hosts the engine or poller actually observed are remembered; a host guessed from command text (~ or ?) does not count as seen. Hosts are remembered across sessions, so the flag shows only in the session that first saw the host. The first run flags every host, as none are known yet. With the option off, hosts are still remembered, so switching it on later does not flag everything.
$.store, key knownHosts, at most 5000 hosts), used for the new-host flag above. Nothing is sent anywhere.~, and credentials (Authorization headers, bearer tokens, --password=…, API_KEY=…, user:pass@ in URLs, well-known token formats) are masked as *** before they are kept.r.Clone into a folder and load it as a plugin directory:
claude --plugin-dir /path/to/net-connections
Claude Code writes the API types into .claude-plugin/types/ when it loads the mod; tsc -p . type-checks it after that, and claude plugin test . runs the tests.
hooks/register.tsx 1269 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput } from 'claude-code'
3
4import type { NetConn, NetDetail, NetKind, NetMount, NetMounts, NetPrompt, NetRaw, NetStatus, NetView } from '../types'
5import {
6 detectMounts,
7 detectShare,
8 detectShell,
9 detectUncertain,
10 driveMounts,
11 hostOf,
12 parseCimDrives,
13 parseMountOutput,
14 parseNetUse,
15 maskHome,
16 PLACEHOLDER_HOSTS,
17 protocolOf,
18 redact,
19} from './detect'
20import type { Target } from './detect'
21import { POLL_SCRIPT, parsePollLine, portProtocol, shortCommand } from './poller'
22
23const PANE = 'net-connections'
24const TITLE = 'Network connections'
25const MAX_CONNS = 1000
26const MAX_PROMPTS = 300
27
28const conns = atom({ plugin: 'net-connections', key: 'conns' } as const, [])
29const prompts = atom({ plugin: 'net-connections', key: 'prompts' } as const, [])
30const current = atom({ plugin: 'net-connections', key: 'current' } as const, null)
31const agents = atom({ plugin: 'net-connections', key: 'agents' } as const, {})
32const view = atom({ plugin: 'net-connections', key: 'view' } as const, { mode: 'list', filter: 'all', page: 0 })
33const mounts = atom({ plugin: 'net-connections', key: 'mounts' } as const, { platform: 'unknown', list: [], at: 0 })
34const raw = atom({ plugin: 'net-connections', key: 'raw' } as const, {})
35const newHosts = atom({ plugin: 'net-connections', key: 'newHosts' } as const, [])
36
37// Hosts seen in any session, kept in `$.store` between sessions.
38const KNOWN_KEY = 'knownHosts'
39const MAX_KNOWN = 5000
40
41// A raw body kept past this is cut; the pane says how much it left out.
42const RAW_MAX = 100_000
43const MAX_RAW = 20
44// A rendered tree holds at most 100,000 characters and a Text at most 10,000,
45// so the raw body is drawn a page at a time, in Texts of a few lines each.
46const RAW_PAGE = 8_000
47const RAW_CHUNK = 2_000
48
49/**
50 * Text a tree may hold: newlines for every line break, tab and newline the
51 * only control characters, and no bidirectional overrides, which could show
52 * a command as other than it ran.
53 */
54const printable = (s: string) =>
55 s.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '\ufffd')
56
57/** What the pane keeps of a string it records: printable, credentials masked, home directories as `~`. */
58const clean = (s: string) => maskHome(redact(printable(s)))
59
60const cleanDetails = (ds: readonly NetDetail[]) => ds.map(d => ({ key: clean(d.key), value: clean(d.value) }))
61
62function cleanConn<T extends Partial<NetConn>>(c: T): T {
63 const out: Partial<NetConn> = { ...c }
64 for (const k of ['source', 'host', 'url', 'reason', 'statusText', 'command'] as const) {
65 const v = out[k]
66 if (typeof v === 'string') out[k] = clean(v)
67 }
68 if (out.details) out.details = cleanDetails(out.details)
69 return out as T
70}
71
72/** Cuts `s` into pieces of at most `n` characters, at a line break where one is near. */
73function chunks(s: string, n: number): string[] {
74 const out: string[] = []
75 let i = 0
76 while (i < s.length) {
77 let j = Math.min(s.length, i + n)
78 if (j < s.length) {
79 const nl = s.lastIndexOf('\n', j)
80 if (nl > i + n / 2) j = nl + 1
81 }
82 out.push(s.slice(i, j).replace(/\n$/, ''))
83 i = j
84 }
85 return out
86}
87
88const MOUNT_REFRESH_MS = 10 * 60 * 1000
89// Locale-independent, unlike `net use`: one `Z:|\\server\share` line per mapped drive.
90const CIM_DRIVES = "Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=4' | ForEach-Object { $_.DeviceID + '|' + $_.ProviderName }"
91
92// Tools that talk to claude.ai / Anthropic services rather than the local machine.
93const SERVICE_TOOLS = new Set([
94 'Artifact', 'ArtifactData', 'ArtifactComments', 'ArtifactCheck', 'DesignSync', 'ClaudeDesign',
95 'RemoteTrigger', 'ReadNotifications', 'SendUserFile', 'SendFile', 'FetchInboxMessage',
96 'SearchMcpRegistry', 'SearchPlugins', 'SearchSkills', 'SendFeedback', 'Projects',
97])
98const SHELL_TOOLS = new Set(['Bash', 'PowerShell', 'Monitor'])
99// Path arguments a file tool may point at a UNC share through.
100const PATH_FIELDS = ['file_path', 'notebook_path', 'path'] as const
101
102const KIND_COLOR: Record<NetKind, string> = {
103 model: 'magenta',
104 web: 'cyan',
105 shell: 'yellow',
106 mcp: 'blue',
107 service: 'green',
108 share: '#ff8700',
109 local: 'gray',
110}
111const KIND_LABEL: Record<NetKind, string> = {
112 model: 'MODEL',
113 web: 'WEB',
114 shell: 'SHELL',
115 mcp: 'MCP',
116 service: 'SVC',
117 share: 'SHARE',
118 local: 'LOCAL',
119}
120// The mark beside a row's status: how sure the pane is that it touched the network.
121const EVIDENCE_MARK: Record<NetConn['confidence'], string> = {
122 observed: ' ',
123 inferred: '~',
124 uncertain: '?',
125 local: ' ',
126}
127const EVIDENCE_TEXT: Record<NetConn['confidence'], string> = {
128 observed: 'observed by the engine',
129 inferred: 'inferred from the command text',
130 uncertain: 'uncertain: the command runs code whose connections are not visible',
131 local: 'local: no known network access',
132}
133
134const STATUS_MARK: Record<NetStatus, { glyph: string; color: string }> = {
135 running: { glyph: '…', color: 'yellow' },
136 ok: { glyph: '✓', color: 'green' },
137 error: { glyph: '✗', color: 'red' },
138 denied: { glyph: '⊘', color: 'gray' },
139}
140
141type $ = EngineInterface
142type Loose = Record<string, unknown>
143
144// ---------- formatting ----------
145
146const pad2 = (n: number) => String(n).padStart(2, '0')
147const fmtTime = (ms: number) => {
148 const d = new Date(ms)
149 return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
150}
151const fmtDur = (ms?: number) =>
152 ms === undefined ? '' : ms < 1000 ? `${Math.round(ms)}ms` : ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`
153const fmtBytes = (b?: number) =>
154 b === undefined ? '' : b < 1024 ? `${b}B` : b < 1024 * 1024 ? `${(b / 1024).toFixed(1)}KB` : `${(b / 1024 / 1024).toFixed(1)}MB`
155const fmtNum = (n: number) => (n >= 10_000 ? `${(n / 1000).toFixed(1)}k` : String(n))
156const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
157const trunc = (s: string, n: number) => (n <= 1 ? '' : s.length > n ? `${s.slice(0, n - 1)}…` : s)
158const durOf = (c: NetConn) => (c.endedAt === undefined ? undefined : c.endedAt - c.startedAt)
159
160// ---------- recording ----------
161
162let idCounter = 0
163const mkId = (now: number) => `${now.toString(36)}-${(idCounter++).toString(36)}`
164
165let apiHostCache: string | undefined
166async function apiHost($: $): Promise<string> {
167 if (apiHostCache !== undefined) return apiHostCache
168 const base = await $.env.get('ANTHROPIC_BASE_URL')
169 if (base) apiHostCache = hostOf(base)
170 else if (await $.env.get('CLAUDE_CODE_USE_BEDROCK')) apiHostCache = `bedrock-runtime.${(await $.env.get('AWS_REGION')) ?? 'us-east-1'}.amazonaws.com`
171 else if (await $.env.get('CLAUDE_CODE_USE_VERTEX')) apiHostCache = `${(await $.env.get('CLOUD_ML_REGION')) ?? 'us-east5'}-aiplatform.googleapis.com`
172 else apiHostCache = 'api.anthropic.com'
173 return apiHostCache
174}
175
176async function originFor($: $, agentId: string | undefined): Promise<string | undefined> {
177 const cur = (await read($, current)) ?? undefined
178 if (agentId === undefined) return cur
179 const known = (await read($, agents))[agentId]
180 if (known !== undefined) return known
181 if (cur !== undefined) await update($, agents, m => (agentId in m ? m : { ...m, [agentId]: cur }))
182 return cur
183}
184
185// The `flagNewHosts` option, set by `register`.
186let flagNewHosts = true
187
188// Store reads and writes run one after the other, so two hosts noted at once both land.
189let noting: Promise<unknown> = Promise.resolve()
190
191/**
192 * Records a host in the cross-session store. A host the store had not seen
193 * before is new in this session: it joins `newHosts`, which the pane flags
194 * until the session ends. The store always learns hosts, so switching the
195 * flag on later does not flag everything.
196 */
197function noteHost($: $, host: string | undefined): Promise<unknown> {
198 if (host === undefined || host === '' || PLACEHOLDER_HOSTS.has(host)) return noting
199 noting = noting.then(async () => {
200 const stored = await $.store.get(KNOWN_KEY)
201 const known = Array.isArray(stored) ? stored.filter((h): h is string => typeof h === 'string') : []
202 if (known.includes(host)) return
203 await $.store.set(KNOWN_KEY, [...known, host].slice(-MAX_KNOWN))
204 if (flagNewHosts) await update($, newHosts, list => (list.includes(host) ? list : [...list, host]))
205 }).catch(() => {})
206 return noting
207}
208
209async function addConn($: $, c: Omit<NetConn, 'seq'>) {
210 const kept = cleanConn(c)
211 await update($, conns, list => [...list, { ...kept, seq: (list.at(-1)?.seq ?? 0) + 1 }].slice(-MAX_CONNS))
212 // Only a host the engine or poller saw counts: a host guessed from command text may never be contacted.
213 if (kept.kind !== 'local' && kept.confidence === 'observed') await noteHost($, kept.host)
214}
215
216async function patchConn($: $, id: string, p: Partial<NetConn>, more: NetDetail[] = []) {
217 const kept = cleanConn(p)
218 const added = cleanDetails(more)
219 await update($, conns, list =>
220 list.map(c => (c.id === id ? { ...c, ...kept, details: [...c.details, ...added] } : c)),
221 )
222 if (kept.host !== undefined && (await read($, conns)).find(c => c.id === id)?.confidence === 'observed') await noteHost($, kept.host)
223}
224
225async function addPrompt($: $, p: Omit<NetPrompt, 'seq'>) {
226 const kept = { ...p, text: clean(p.text) }
227 await update($, prompts, list => [...list, { ...kept, seq: (list.at(-1)?.seq ?? 0) + 1 }].slice(-MAX_PROMPTS))
228 await update($, current, () => p.id)
229}
230
231function inputSummary(e: Loose): string {
232 const { tool: _t, tool_use_id: _i, agentId: _a, consent: _c, ...args } = e
233 try {
234 return JSON.stringify(args, null, 0)
235 } catch {
236 return String(args)
237 }
238}
239
240type Classified = { kind: NetKind; targets: Target[]; command?: string; confidence: NetConn['confidence'] }
241
242async function classify($: $, e: Loose): Promise<Classified> {
243 const tool = String(e.tool)
244 if (tool === 'WebFetch' && typeof e.url === 'string') {
245 return { kind: 'web', confidence: 'observed', command: e.url, targets: [{ host: hostOf(e.url), url: e.url, protocol: protocolOf(e.url), reason: 'WebFetch tool' }] }
246 }
247 if (tool === 'WebSearch') {
248 return { kind: 'web', confidence: 'observed', command: String(e.query ?? ''), targets: [{ host: await apiHost($), protocol: 'HTTPS', reason: 'server-side web search run by the API' }] }
249 }
250 if (SHELL_TOOLS.has(tool)) {
251 const ws = (e.ws as Loose | undefined)?.url
252 if (typeof ws === 'string') {
253 return { kind: 'shell', confidence: 'observed', command: ws, targets: [{ host: hostOf(ws), url: ws, protocol: protocolOf(ws), reason: 'Monitor WebSocket' }] }
254 }
255 if (typeof e.command !== 'string') return localCall(tool, e)
256 const shares = (await shareTargets($, e.command)).map(t => ({ ...t, kind: 'share' as const }))
257 const targets = [...detectShell(e.command), ...shares]
258 if (targets.length > 0) return { kind: targets.length === shares.length ? 'share' : 'shell', confidence: 'inferred', command: e.command, targets }
259 const maybe = detectUncertain(e.command)
260 if (maybe !== undefined) return { kind: 'shell', confidence: 'uncertain', command: e.command, targets: [maybe] }
261 return localCall(tool, e)
262 }
263 if (tool.startsWith('mcp__')) {
264 const [, server = '?', name = '?'] = tool.split('__')
265 if (server.startsWith('claude_ai_')) {
266 return { kind: 'service', confidence: 'observed', command: `${server} → ${name}`, targets: [{ host: 'claude.ai (MCP proxy)', protocol: 'HTTPS', reason: `claude.ai connector ${server.slice(10)}` }] }
267 }
268 return { kind: 'mcp', confidence: 'observed', command: `${server} → ${name}`, targets: [{ host: `mcp:${server}`, protocol: 'MCP', reason: 'MCP server call (stdio servers stay local; http/sse servers go out)' }] }
269 }
270 if (SERVICE_TOOLS.has(tool)) {
271 const url = typeof e.url === 'string' ? e.url : undefined
272 return { kind: 'service', confidence: 'observed', command: tool, targets: [{ host: url ? hostOf(url) : 'claude.ai', url, protocol: 'HTTPS', reason: `${tool} talks to claude.ai` }] }
273 }
274 const paths = PATH_FIELDS.map(k => e[k]).filter((v): v is string => typeof v === 'string')
275 const shares = (await Promise.all(paths.map(path => shareTargets($, path)))).flat()
276 if (shares.length > 0) {
277 return { kind: 'share', confidence: 'observed', command: paths.join(' '), targets: shares.map(t => ({ ...t, reason: `${tool} on a ${t.reason}` })) }
278 }
279 return localCall(tool, e)
280}
281
282/** Any other tool call: recorded as local so each prompt's calls are complete. */
283function localCall(tool: string, e: Loose): Classified {
284 const what = ['command', 'file_path', 'notebook_path', 'pattern', 'path', 'skill', 'query', 'description', 'prompt']
285 .map(k => e[k])
286 .find((v): v is string => typeof v === 'string' && v !== '')
287 return {
288 kind: 'local',
289 confidence: 'local',
290 command: what ?? trunc(inputSummary(e), 200),
291 targets: [{ host: 'local', protocol: '—', reason: `${tool} has no known network access` }],
292 }
293}
294
295function resultDetails(tool: string, r: Loose | undefined, text: string | undefined): { p: Partial<NetConn>; more: NetDetail[] } {
296 const more: NetDetail[] = []
297 const p: Partial<NetConn> = {}
298 if (r === undefined) return { p, more }
299 if (tool === 'WebFetch') {
300 if (typeof r.code === 'number') p.statusText = `HTTP ${r.code} ${String(r.codeText ?? '')}`.trim()
301 if (typeof r.bytes === 'number') p.bytes = r.bytes
302 if (typeof r.durationMs === 'number') more.push({ key: 'Fetch time', value: fmtDur(r.durationMs) })
303 if (typeof r.url === 'string') more.push({ key: 'Final URL', value: r.url })
304 if (typeof r.result === 'string' && r.result.trim()) more.push({ key: 'Response', value: trunc(r.result.trim(), 2000) })
305 } else if (tool === 'WebSearch') {
306 const hits: string[] = []
307 for (const block of (r.results as unknown[]) ?? []) {
308 if (typeof block === 'object' && block !== null) {
309 for (const hit of ((block as Loose).content as Loose[]) ?? []) hits.push(`${String(hit.title)} — ${String(hit.url)}`)
310 }
311 }
312 p.statusText = `${hits.length} results`
313 if (typeof r.searchCount === 'number') more.push({ key: 'Searches', value: String(r.searchCount) })
314 if (typeof r.durationSeconds === 'number') more.push({ key: 'Search time', value: fmtDur(r.durationSeconds * 1000) })
315 const hosts = [...new Set(hits.map(h => hostOf(h.split(' — ').at(-1) ?? '')))]
316 if (hosts.length) more.push({ key: 'Result hosts', value: hosts.join(', ') })
317 hits.slice(0, 10).forEach((h, i) => more.push({ key: `Result ${i + 1}`, value: h }))
318 const notes = ((r.results as unknown[]) ?? []).filter((x): x is string => typeof x === 'string' && x.trim() !== '')
319 if (notes.length) more.push({ key: 'Response', value: trunc(notes.join('\n').trim(), 2000) })
320 } else if (SHELL_TOOLS.has(tool)) {
321 if (r.interrupted === true) p.statusText = 'interrupted'
322 if (typeof r.backgroundTaskId === 'string') more.push({ key: 'Background task', value: r.backgroundTaskId })
323 if (typeof r.timedOutAfterMs === 'number') more.push({ key: 'Timed out after', value: fmtDur(r.timedOutAfterMs) })
324 if (r.dangerouslyDisableSandbox === true) more.push({ key: 'Sandbox', value: 'overridden' })
325 if (typeof r.stdout === 'string') p.bytes = r.stdout.length + (typeof r.stderr === 'string' ? r.stderr.length : 0)
326 if (typeof r.stderr === 'string' && r.stderr.trim()) more.push({ key: 'stderr (head)', value: trunc(r.stderr.trim(), 400) })
327 if (typeof r.stdout === 'string' && r.stdout.trim()) more.push({ key: 'stdout (head)', value: trunc(r.stdout.trim(), 400) })
328 } else if (text !== undefined) {
329 p.bytes = text.length
330 more.push({ key: 'Response (head)', value: trunc(oneLine(text), 300) })
331 }
332 return { p, more }
333}
334
335/**
336 * Fetches a WebFetch connection's URL again, as the pane's own request, and
337 * keeps the raw body under the connection's id. WebFetch hands mods only its
338 * processed output, so this second request is the only way to the bytes; it
339 * is listed as a connection of its own.
340 */
341async function fetchRaw($: $, c: NetConn) {
342 const url = c.details.find(d => d.key === 'Final URL')?.value ?? c.url
343 if (url === undefined) return
344 const at = await $.clock.now()
345 const put = (r: NetRaw) => update($, raw, m => Object.fromEntries([...Object.entries(m).filter(([k]) => k !== c.id), [c.id, r]].slice(-MAX_RAW)))
346 await put({ state: 'loading', url, at })
347 const id = mkId(at)
348 await addConn($, {
349 id,
350 kind: 'web',
351 source: 'net-connections',
352 host: hostOf(url),
353 url,
354 protocol: protocolOf(url),
355 confidence: 'observed',
356 reason: `raw response of #${c.seq}, fetched again by this pane`,
357 startedAt: at,
358 status: 'running',
359 promptId: c.promptId,
360 command: url,
361 details: [],
362 })
363 try {
364 const res = await $.http.fetch(url)
365 const end = await $.clock.now()
366 await put({ state: 'ok', url, at, status: res.status, headers: res.headers, text: printable(res.text.slice(0, RAW_MAX)), length: res.text.length })
367 await patchConn($, id, { endedAt: end, status: res.ok ? 'ok' : 'error', statusText: `HTTP ${res.status}`, bytes: res.text.length }, [
368 { key: 'Content type', value: res.headers['content-type'] ?? '—' },
369 ])
370 } catch (err) {
371 const end = await $.clock.now()
372 const msg = String((err as Error)?.message ?? err)
373 await put({ state: 'error', url, at, error: msg })
374 await patchConn($, id, { endedAt: end, status: 'error', statusText: trunc(msg, 120) })
375 }
376}
377
378// ---------- watching shell processes (Windows) ----------
379
380// The poller (./poller) stops this long after the last shell call ended, once nothing it saw is open.
381const WATCH_IDLE_MS = 30_000
382const WATCH_MAX_MS = 60 * 60 * 1000
383// A shell call's shell starts a moment before the hook runs; the poller counts processes from this far back.
384const WATCH_SLACK_MS = 5_000
385const MAX_SHELL_CALLS = 200
386
387type ShellCall = {
388 tool: string
389 toolUseId?: string
390 agentId?: string
391 promptId?: string
392 command?: string
393 start: number
394 end?: number
395 /** The call's own rows, which list what the poller saw it connect to. */
396 connIds: string[]
397}
398
399
400const shellCalls: ShellCall[] = []
401const namesByAddr = new Map<string, string[]>()
402let watching = false
403// Set once the poller could not start (no PowerShell, a refused spawn): shell calls stay inferred.
404let watchBroken = false
405let lastShellActivity = 0
406let onWindowsCache: boolean | undefined
407
408async function onWindows($: $): Promise<boolean> {
409 onWindowsCache ??= (await $.env.get('OS')) === 'Windows_NT'
410 return onWindowsCache
411}
412
413/** The shell call running when a process Claude Code started came up: the latest one, a second's slack each side. */
414function callAt(started: number): ShellCall | undefined {
415 let best: ShellCall | undefined
416 for (const c of shellCalls) {
417 if (c.start - 1000 <= started && (c.end === undefined || started <= c.end + 1000) && (best === undefined || c.start > best.start)) best = c
418 }
419 return best
420}
421
422/**
423 * Runs the poller while shell calls run and a while after, recording each
424 * connection it sees as an observed row of the call whose processes made it.
425 * One poller for the session: started by the first shell call, it stops once
426 * idle and the next shell call starts it again.
427 */
428async function watchShells($: $) {
429 if (watching || watchBroken) return
430 watching = true
431 const t0 = (await $.clock.now()) - WATCH_SLACK_MS
432 const open = new Map<string, string>()
433 const byAddr = new Map<string, string[]>()
434 const byTop = new Map<number, ShellCall | null>()
435 let buf = ''
436 let ready = false
437 try {
438 const stream = $.process.spawn({
439 argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', 'iex ([Console]::In.ReadToEnd())'],
440 env: { NETCONN_T0: String(t0), NETCONN_MAX_MS: String(WATCH_MAX_MS), NETCONN_EVERY_MS: '25' },
441 input: POLL_SCRIPT,
442 })
443 for await (const chunk of stream) {
444 if (chunk.stream !== 'stdout') continue
445 buf += chunk.text
446 for (let nl = buf.indexOf('\n'); nl >= 0; nl = buf.indexOf('\n')) {
447 const ev = parsePollLine(buf.slice(0, nl).trim())
448 buf = buf.slice(nl + 1)
449 if (ev === undefined) continue
450 const at = await $.clock.now()
451 if (ev.ev === 'ready') ready = true
452 else if (ev.ev === 'open') {
453 if (!byTop.has(ev.top)) byTop.set(ev.top, callAt(ev.topStart) ?? null)
454 const call = byTop.get(ev.top) ?? undefined
455 const names = namesByAddr.get(ev.addr)
456 const proc = `${ev.name ?? 'process'} (PID ${ev.pid})`
457 const id = mkId(at)
458 await addConn($, {
459 id,
460 kind: 'shell',
461 source: call?.tool ?? 'Claude Code',
462 host: names?.[0] ?? ev.addr,
463 protocol: portProtocol(ev.port),
464 confidence: 'observed',
465 reason: `${proc} connected; seen in the TCP table`,
466 seenByPoller: true,
467 startedAt: at,
468 status: 'running',
469 promptId: call?.promptId ?? (await originFor($, undefined)),
470 agentId: call?.agentId,
471 toolUseId: call?.toolUseId,
472 command: ev.cmd ? shortCommand(ev.cmd) : ev.name,
473 details: [
474 { key: 'Process', value: proc },
475 ...(ev.cmd ? [{ key: 'Command line', value: ev.cmd }] : []),
476 { key: 'Remote', value: `${ev.addr} port ${ev.port}` },
477 { key: 'Local', value: ev.local },
478 ...(names ? [{ key: 'DNS names', value: names.join(', ') }] : []),
479 {
480 key: 'Started by',
481 value: call
482 ? `${call.tool} call${call.command ? `: ${trunc(oneLine(call.command), 300)}` : ''}`
483 : 'a process Claude Code started outside any shell call (a hook, an MCP server, git, …)',
484 },
485 ],
486 })
487 open.set(ev.key, id)
488 byAddr.set(ev.addr, [...(byAddr.get(ev.addr) ?? []), id])
489 } else if (ev.ev === 'close') {
490 const id = open.get(ev.key)
491 open.delete(ev.key)
492 if (id !== undefined) await patchConn($, id, { endedAt: at, status: 'ok', statusText: 'closed' })
493 } else if (ev.ev === 'names') {
494 namesByAddr.set(ev.addr, ev.names)
495 for (const id of byAddr.get(ev.addr) ?? []) {
496 await patchConn($, id, { host: ev.names[0] }, [{ key: 'DNS names', value: ev.names.join(', ') }])
497 }
498 byAddr.delete(ev.addr)
499 }
500 }
501 const idle = !shellCalls.some(c => c.end === undefined) && open.size === 0
502 if (idle && (await $.clock.now()) - lastShellActivity > WATCH_IDLE_MS) break
503 }
504 } catch {
505 watchBroken = true
506 } finally {
507 // A poller that never got ready will not get ready the next time either.
508 if (!ready) watchBroken = true
509 watching = false
510 const end = await $.clock.now()
511 for (const id of open.values()) await patchConn($, id, { endedAt: end, status: 'ok', statusText: 'last seen' })
512 if (!watchBroken && shellCalls.some(c => c.end === undefined)) void watchShells($)
513 }
514}
515
516// ---------- drawing ----------
517
518function setView($: $, fn: (v: NetView) => NetView) {
519 return update($, view, fn)
520}
521
522const MAX_BACK = 30
523
524/** Moves to another view and remembers the one it leaves, for `b`. */
525function navigate($: $, fn: (v: NetView) => NetView) {
526 return update($, view, v => {
527 const { back = [], ...here } = v
528 return { ...fn(v), back: [...back, here].slice(-MAX_BACK) }
529 })
530}
531
532/**
533 * Returns to the view `navigate` left; with none left, to the list. Back in
534 * the list, the ring goes to the row the details were of.
535 */
536async function goBack($: $) {
537 const from = await read($, view)
538 const now = await update($, view, (v): NetView => {
539 const back = v.back ?? []
540 const prev = back.at(-1)
541 return prev !== undefined ? { ...prev, back: back.slice(0, -1) } : { ...v, mode: 'list', back: [] }
542 })
543 if (from.mode === 'detail' && from.selected !== undefined && now.mode === 'list') {
544 selectedRow = `c-${from.selected}`
545 await $.ui.focus({ requestId: PANE, key: selectedRow }).catch(() => {})
546 }
547}
548
549/**
550 * The file-share targets a path or command names: UNC paths (Windows only;
551 * `\\` and `//` mean nothing of the kind on macOS and Linux) and paths on a
552 * network drive or mount.
553 */
554async function shareTargets($: $, text: string): Promise<Target[]> {
555 const m = await read($, mounts)
556 return [...(m.platform === 'posix' ? [] : detectShare(text)), ...detectMounts(text, m.list)]
557}
558
559async function run($: $, argv: readonly string[]): Promise<string | undefined> {
560 try {
561 const r = await $.process.run(argv, { timeoutMs: 15_000 })
562 return r.exitCode === 0 ? r.stdout : undefined
563 } catch {
564 return undefined // not installed on this system
565 }
566}
567
568/**
569 * Reads the network drives (Windows: CIM, else `net use`) or mounts (macOS,
570 * Linux: `mount`) into state. A failed lookup keeps the last list and says why.
571 */
572async function loadMounts($: $): Promise<NetMounts> {
573 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
574 const now = await $.clock.now()
575 let list: NetMount[] | undefined
576 let error: string | undefined
577 if (isWindows) {
578 const cim = await run($, ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', CIM_DRIVES])
579 if (cim !== undefined) list = driveMounts(parseCimDrives(cim))
580 else {
581 const net = await run($, ['net', 'use'])
582 if (net !== undefined) list = driveMounts(parseNetUse(net))
583 else error = 'neither Get-CimInstance nor net use answered'
584 }
585 } else {
586 const out = await run($, ['mount'])
587 if (out !== undefined) list = parseMountOutput(out)
588 else error = 'mount did not answer'
589 }
590 const platform = isWindows ? 'windows' : 'posix'
591 return update($, mounts, prev => ({
592 platform,
593 list: list ?? prev.list,
594 at: list ? now : prev.at,
595 isLoaded: list !== undefined || prev.isLoaded === true,
596 ...(error ? { error } : {}),
597 }))
598}
599
600function mountLine(m: NetMount): string {
601 return `${m.root} → ${m.source} (${m.protocol})`
602}
603
604export const register: Register = (on, options) => {
605 flagNewHosts = options.flagNewHosts !== false
606 let lastCommand: { id: string; at: number; used: boolean } | undefined
607
608 on('session.start', async ($, e, next) => {
609 await $.command.register({
610 name: 'net',
611 description: 'Show the network connections this session made, by prompt and command',
612 // Runs at once while a turn is in flight: opening the pane never needs to wait for the turn or interrupt it.
613 immediate: true,
614 })
615 if (e.isInteractive) void $.ui.open({ id: PANE, title: TITLE })
616 void loadMounts($)
617 $.clock.every(MOUNT_REFRESH_MS, () => void loadMounts($))
618 return next(e)
619 })
620
621 on('command.run', { command: 'net' }, async ($, e) => {
622 const arg = e.args.trim()
623 if (arg === 'clear') {
624 await update($, conns, () => [])
625 await update($, prompts, () => [])
626 await update($, current, () => null)
627 await update($, agents, () => ({}))
628 await setView($, () => ({ mode: 'list', filter: 'all', page: 0 }))
629 return { text: 'Network log cleared.' }
630 }
631 if (arg === 'drives' || arg === 'mounts') {
632 const m = await loadMounts($)
633 const what = m.platform === 'windows' ? 'mapped network drives' : 'network mounts'
634 if (m.error !== undefined && m.isLoaded !== true) return { text: `Could not list ${what}: ${m.error}.` }
635 const head = m.list.length === 0 ? `No ${what}.` : `${what[0]!.toUpperCase()}${what.slice(1)}:\n${m.list.map(x => ` ${mountLine(x)}`).join('\n')}`
636 return { text: m.error !== undefined ? `${head}\n(last lookup failed: ${m.error}; showing the previous list)` : head }
637 }
638 const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
639 const n = (await read($, conns)).length
640 const so = `${n} connection${n === 1 ? '' : 's'} so far`
641 if (!opened.isPlaced) return { text: `Network pane is open but not shown here (${so}): ${opened.reason}` }
642 return { text: `Network pane opened (${so}).` }
643 })
644
645 // Slash commands are origins too: a command that runs a turn keeps its name.
646 on('command.run', async ($, e, next) => {
647 if (e.command === 'net') return next(e)
648 const now = await $.clock.now()
649 const id = `cmd-${mkId(now)}`
650 await addPrompt($, { id, kind: 'command', text: `/${e.command}${e.args ? ` ${e.args}` : ''}`, at: now })
651 lastCommand = { id, at: now, used: false }
652 return next(e)
653 })
654
655 on('turn.start', async ($, e, next) => {
656 const now = await $.clock.now()
657 if (lastCommand !== undefined && !lastCommand.used && now - lastCommand.at < 5000) {
658 lastCommand.used = true
659 await update($, current, () => lastCommand!.id)
660 } else {
661 await addPrompt($, {
662 id: e.turnId,
663 kind: e.text ? 'prompt' : 'other',
664 text: e.text || '(continuation / background notification)',
665 at: now,
666 })
667 }
668 return next(e)
669 })
670
671 // Every model request is an HTTPS stream to the API.
672 on('turn.step', async function* ($, e, next) {
673 const t0 = await $.clock.now()
674 const id = mkId(t0)
675 const host = await apiHost($)
676 await addConn($, {
677 id,
678 kind: 'model',
679 source: e.agentId ? 'subagent model request' : 'model request',
680 host,
681 url: `https://${host}/v1/messages`,
682 protocol: 'HTTPS (SSE stream)',
683 confidence: 'observed',
684 reason: 'turn step: one Messages API request',
685 startedAt: t0,
686 status: 'running',
687 promptId: await originFor($, e.agentId),
688 agentId: e.agentId,
689 command: `${e.model} · step ${e.index} · ${e.messageCount} messages`,
690 details: [
691 { key: 'Model', value: e.model },
692 ...(e.effort !== undefined ? [{ key: 'Effort', value: String(e.effort) }] : []),
693 { key: 'Turn', value: e.turnId },
694 { key: 'Step', value: String(e.index) },
695 { key: 'Messages sent', value: String(e.messageCount) },
696 ],
697 })
698 let firstAt: number | undefined
699 let chunks = 0
700 let outChars = 0
701 try {
702 const stream = next(e)
703 for await (const chunk of stream) {
704 if (firstAt === undefined && chunk.kind !== 'engine') firstAt = await $.clock.now()
705 chunks += 1
706 if (chunk.kind === 'text') outChars += chunk.text.length
707 if (chunk.kind === 'input') outChars += chunk.json.length
708 yield chunk
709 }
710 const r = await stream.result
711 const end = await $.clock.now()
712 const u = r.usage
713 const more: NetDetail[] = [
714 { key: 'Time to first chunk', value: firstAt === undefined ? '—' : fmtDur(firstAt - t0) },
715 { key: 'Stream chunks', value: String(chunks) },
716 { key: 'Stop reason', value: String(r.stopReason ?? 'none (failed or interrupted)') },
717 ]
718 if (u) {
719 more.push(
720 { key: 'Answered by', value: u.model },
721 { key: 'Input tokens', value: String(u.input_tokens) },
722 { key: 'Cache read tokens', value: String(u.cache_read_input_tokens) },
723 { key: 'Cache write tokens', value: String(u.cache_creation_input_tokens) },
724 { key: 'Output tokens', value: String(u.output_tokens) },
725 )
726 }
727 if (r.toolUses.length) more.push({ key: 'Tools requested', value: r.toolUses.map(t => String((t as Loose).name ?? (t as Loose).tool ?? '?')).join(', ') })
728 if (r.answer) more.push({ key: 'Answer (head)', value: trunc(oneLine(r.answer), 300) })
729 await patchConn(
730 $,
731 id,
732 {
733 endedAt: end,
734 status: r.stopReason === null ? 'error' : 'ok',
735 statusText: u ? `${fmtNum(u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens)} in / ${fmtNum(u.output_tokens)} out` : String(r.stopReason ?? 'no response'),
736 bytes: outChars || undefined,
737 },
738 more,
739 )
740 return r
741 } catch (err) {
742 await patchConn($, id, { endedAt: await $.clock.now(), status: 'error', statusText: String((err as Error)?.message ?? err).slice(0, 120) })
743 throw err
744 }
745 })
746
747 // Tools that reach the network: web, shell commands that look networked, MCP, claude.ai services.
748 on('tool.call', async ($, e, next) => {
749 const loose = e as unknown as Loose
750 const found = await classify($, loose)
751 const t0 = await $.clock.now()
752 const promptId = await originFor($, e.agentId)
753 const ids: string[] = []
754 for (const t of found.targets) {
755 const id = mkId(t0)
756 ids.push(id)
757 await addConn($, {
758 id,
759 kind: t.kind ?? found.kind,
760 source: String(e.tool),
761 host: t.host,
762 url: t.url,
763 protocol: t.protocol,
764 confidence: found.confidence,
765 reason: t.reason,
766 startedAt: t0,
767 status: 'running',
768 promptId,
769 agentId: e.agentId,
770 toolUseId: e.tool_use_id,
771 command: found.command,
772 details: [{ key: 'Tool input', value: trunc(inputSummary(loose), 600) }],
773 })
774 }
775 let shell: ShellCall | undefined
776 if (SHELL_TOOLS.has(String(e.tool)) && (await onWindows($))) {
777 shell = { tool: String(e.tool), toolUseId: e.tool_use_id, agentId: e.agentId, promptId, command: typeof loose.command === 'string' ? loose.command : undefined, start: t0, connIds: ids }
778 shellCalls.push(shell)
779 shellCalls.splice(0, Math.max(0, shellCalls.length - MAX_SHELL_CALLS))
780 lastShellActivity = t0
781 void watchShells($)
782 }
783 let ran
784 try {
785 ran = await next(e)
786 } catch (err) {
787 const end = await $.clock.now()
788 if (shell) {
789 shell.end = end
790 lastShellActivity = end
791 }
792 for (const id of ids) await patchConn($, id, { endedAt: end, status: 'error', statusText: String((err as Error)?.message ?? err).slice(0, 120) })
793 throw err
794 }
795 const end = await $.clock.now()
796 if (shell) {
797 shell.end = end
798 lastShellActivity = end
799 }
800 const status: NetStatus = ran.deny !== undefined ? 'denied' : ran.isError ? 'error' : 'ok'
801 const { p, more } = resultDetails(String(e.tool), ran.result as Loose | undefined, ran.text)
802 if (ran.deny !== undefined) more.push({ key: 'Denied', value: ran.deny })
803 if (ran.isError && ran.text) more.push({ key: 'Error', value: trunc(oneLine(ran.text), 400) })
804 for (const id of ids) {
805 await patchConn($, id, { endedAt: end, status, statusText: p.statusText ?? (status === 'ok' ? 'done' : status), ...(p.bytes !== undefined ? { bytes: p.bytes } : {}) }, more)
806 }
807 return ran
808 })
809
810 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
811 const { Box } = $.ui.resolve(e)
812 const body = await renderPane($, e)
813 return (
814 <Box flexDirection="column" flexGrow={1} backgroundColor="black" minHeight={e.viewport?.rows}>
815 {body}
816 </Box>
817 )
818 })
819
820 // The selected row follows the ring over the list's rows; the ring in other views, or under the pointer, leaves it be.
821 on('ui.focus', { component: 'Pane', requestId: PANE }, ($, e, next) => {
822 if (e.element?.startsWith('c-')) selectedRow = e.element
823 return next(e)
824 })
825
826 // Up and down move the ring through the list's rows instead of scrolling the pane.
827 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
828 if (e.origin.kind !== 'person' || Math.abs(e.by) !== 1 || listRows.length === 0) return next(e)
829 const at = selectedRow === undefined ? -1 : listRows.indexOf(selectedRow)
830 const to = at < 0 ? (e.by > 0 ? 0 : listRows.length - 1) : Math.min(listRows.length - 1, Math.max(0, at + e.by))
831 if (to !== at) {
832 selectedRow = listRows[to]!
833 await $.ui.focus({ requestId: PANE, key: selectedRow })
834 }
835 return {}
836 })
837}
838
839// The list view's row buttons, top to bottom, as last drawn; empty in every other view.
840let listRows: string[] = []
841// The list row last selected (its button key): by the arrows, a click or Enter, or the ring.
842let selectedRow: string | undefined
843
844/** The pane's content; `ui.render` paints it on a black ground. */
845async function renderPane($: $, e: RenderInput<'Pane'>) {
846 const { Box, Text, Button } = $.ui.resolve(e)
847 listRows = []
848 const all = await read($, conns)
849 const ps = await read($, prompts)
850 const v = await read($, view)
851 const fresh = new Set(await read($, newHosts))
852 const width = Math.max(30, e.props.bodyColumns)
853 const rows = Math.max(8, (e.viewport?.rows ?? 30) - 2)
854 const promptById = new Map(ps.map(p => [p.id, p]))
855 // A row the poller saw belongs to the tool call with its tool use id: that call's own row.
856 const isSeen = (c: NetConn) => c.seenByPoller === true
857 const callRows = new Map<string, NetConn>()
858 for (const c of all) if (c.toolUseId !== undefined && !isSeen(c) && !callRows.has(c.toolUseId)) callRows.set(c.toolUseId, c)
859 const parentOf = (c: NetConn) => (isSeen(c) && c.toolUseId !== undefined ? callRows.get(c.toolUseId) : undefined)
860 const seenOf = new Map<string, NetConn[]>()
861 for (const c of all) {
862 const p = parentOf(c)
863 if (p !== undefined) seenOf.set(p.id, [...(seenOf.get(p.id) ?? []), c])
864 }
865 const promptLabel = (id?: string) => {
866 const p = id === undefined ? undefined : promptById.get(id)
867 return p === undefined ? '(no prompt)' : `${p.kind === 'command' ? 'C' : 'P'}${p.seq}`
868 }
869
870 const showLocal = v.showLocal === true
871 const net = all.filter(c => c.kind !== 'local')
872 const networked = (c: NetConn) => c.kind === 'local' && seenOf.has(c.id)
873 const nNetworked = all.filter(networked).length
874 const counts: Record<string, number> = { all: showLocal ? all.length : net.length + nNetworked, model: 0, web: 0, shell: nNetworked, mcp: 0, service: 0, share: 0, local: 0 }
875 for (const c of all) counts[c.kind] = (counts[c.kind] ?? 0) + 1
876 const uncertain = net.filter(c => c.confidence === 'uncertain').length
877 const hosts = new Set(net.map(c => c.host).filter(h => !PLACEHOLDER_HOSTS.has(h)))
878 const tokIn = all.reduce((n, c) => n + Number(c.details.find(d => d.key === 'Input tokens')?.value ?? 0) + Number(c.details.find(d => d.key === 'Cache read tokens')?.value ?? 0), 0)
879 const tokOut = all.reduce((n, c) => n + Number(c.details.find(d => d.key === 'Output tokens')?.value ?? 0), 0)
880 const running = net.filter(c => c.status === 'running').length
881 const nNew = [...hosts].filter(h => fresh.has(h)).length
882
883 // What the view shows right now, since the buttons alone make the choice hard to see.
884 const viewName = { list: 'list', detail: 'details', prompts: 'by prompt', hosts: 'by host' }[v.mode]
885 const showing = [
886 `view: ${viewName}`,
887 ...(v.mode === 'list'
888 ? [
889 `kind: ${v.filter === 'all' ? 'all' : KIND_LABEL[v.filter].toLowerCase()}`,
890 ...(v.promptId !== undefined ? [`prompt: ${promptLabel(v.promptId || undefined)}`] : []),
891 ...(v.host !== undefined ? [`host: ${v.host}`] : []),
892 ]
893 : []),
894 ...(v.mode === 'hosts' ? [`hosts: ${{ session: 'this session', new: 'new in this session', known: 'all known' }[v.hostSet ?? 'session']}`] : []),
895 `local tool calls: ${showLocal ? 'shown' : 'hidden'}`,
896 ].join(' · ')
897
898 const header = (
899 <Box flexDirection="column">
900 <Text wrap="truncate-end">
901 <Text bold color="cyan">NETWORK CONNECTIONS</Text>
902 <Text bold>{` · ${net.length} connections`}</Text>
903 {uncertain > 0 ? <Text color="yellow">{` (${uncertain} uncertain)`}</Text> : null}
904 {counts.share ? <Text color={KIND_COLOR.share} bold>{` · ⇄ ${counts.share} file-share access${counts.share === 1 ? '' : 'es'}`}</Text> : null}
905 <Text dimColor>{` · ${hosts.size} hosts · ${counts.local} local tool calls · ${ps.length} prompts · ${fmtNum(tokIn)}↑ ${fmtNum(tokOut)}↓ tokens`}</Text>
906 {running > 0 ? <Text color="yellow">{` · ${running} open`}</Text> : null}
907 {nNew > 0 ? <Text color="green" bold>{` · ★ ${nNew} new host${nNew === 1 ? '' : 's'}`}</Text> : null}
908 </Text>
909 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
910 <Button key="v-list" plain hotkey="l" dimColor={v.mode !== 'list'} label="list" onPress={() => setView($, x => ({ ...x, mode: 'list', page: 0, back: [] }))} />
911 <Button key="v-prompts" plain hotkey="g" dimColor={v.mode !== 'prompts'} label="by prompt" onPress={() => setView($, x => ({ ...x, mode: 'prompts', page: 0, back: [] }))} />
912 <Button key="v-hosts" plain hotkey="h" dimColor={v.mode !== 'hosts'} label="by host" onPress={() => setView($, x => ({ ...x, mode: 'hosts', page: 0, back: [] }))} />
913 <Text dimColor>│</Text>
914 <Button key="toggle-local" plain hotkey="z" dimColor={!showLocal} label={`local tool calls: ${showLocal ? 'shown' : 'hidden'}`} onPress={() => setView($, x => ({ ...x, showLocal: x.showLocal !== true, page: 0 }))} />
915 </Box>
916 <Text color="cyan" wrap="truncate-end">{`Showing: ${showing}`}</Text>
917 </Box>
918 )
919
920 // ---- detail ----
921 if (v.mode === 'detail') {
922 const list = scoped(all, v, networked)
923 const idx = list.findIndex(c => c.id === v.selected)
924 const c = idx >= 0 ? list[idx] : all.find(x => x.id === v.selected)
925 if (c === undefined) {
926 return (
927 <Box flexDirection="column">
928 {header}
929 <Text dimColor>That connection is gone.</Text>
930 <Button key="back" hotkey="b" label="back" onPress={() => goBack($)} />
931 </Box>
932 )
933 }
934 const p = c.promptId ? promptById.get(c.promptId) : undefined
935 const parent = parentOf(c)
936 const seen = seenOf.get(c.id) ?? []
937 const linked = new Set([parent?.id, ...seen.map(x => x.id)])
938 const siblings = all.filter(x => x.promptId === c.promptId && x.id !== c.id && !linked.has(x.id))
939 const rowLabel = (x: NetConn) => trunc(`#${x.seq} ${KIND_LABEL[x.kind]} ${x.host} ${oneLine(x.command ?? x.statusText ?? '')}`, width - 4)
940 const mark = STATUS_MARK[c.status]
941 const keyW = 20
942 const field = (k: string, value: string, color?: string) => (
943 <Box flexDirection="row">
944 <Box width={keyW} flexShrink={0}><Text dimColor>{k}</Text></Box>
945 <Box flexGrow={1}><Text wrap="wrap" color={color}>{trunc(printable(value), 9_000)}</Text></Box>
946 </Box>
947 )
948 const go = (to?: NetConn) => to && setView($, x => ({ ...x, selected: to.id }))
949 const copyText = c.url ?? c.command ?? c.host
950
951 // WebFetch: its processed output, or the page's raw body fetched again on request.
952 const isFetch = c.source === 'WebFetch' && c.url !== undefined
953 const showRaw = v.response === 'raw'
954 const r = isFetch ? (await read($, raw))[c.id] : undefined
955 const toolOut = c.details.find(d => d.key === 'Response')?.value
956 const body = r?.text === undefined ? '' : printable(r.text)
957 const rawPages = Math.max(1, Math.ceil(body.length / RAW_PAGE))
958 const rawPage = Math.min(Math.max(0, v.rawPage ?? 0), rawPages - 1)
959 const pageText = body.slice(rawPage * RAW_PAGE, (rawPage + 1) * RAW_PAGE)
960 const pickRaw = () => {
961 void setView($, x => ({ ...x, response: 'raw', rawPage: 0 }))
962 if (r === undefined || r.state === 'error') void fetchRaw($, c)
963 }
964 const responseSection = (
965 <Box flexDirection="column" marginTop={1}>
966 <Box flexDirection="row" columnGap={1}>
967 <Text bold>Response</Text>
968 <Button key="resp-tool" plain hotkey="w" dimColor={showRaw} label="tool output" onPress={() => setView($, x => ({ ...x, response: 'tool' }))} />
969 <Button key="resp-raw" plain hotkey="r" dimColor={!showRaw} label="raw page" onPress={pickRaw} />
970 {showRaw && r !== undefined && r.state !== 'loading' ? (
971 <Button key="resp-refetch" plain hotkey="f" label="fetch again" onPress={() => void fetchRaw($, c)} />
972 ) : null}
973 </Box>
974 {!showRaw ? (
975 <Text wrap="wrap" dimColor={toolOut === undefined}>{toolOut ?? (c.status === 'running' ? 'Still running.' : 'WebFetch returned no output.')}</Text>
976 ) : r === undefined ? (
977 <Text dimColor>Press r to fetch the page.</Text>
978 ) : r.state === 'loading' ? (
979 <Text color="yellow">{`Fetching ${r.url} …`}</Text>
980 ) : r.state === 'error' ? (
981 <Text color="red" wrap="wrap">{`Fetch failed: ${r.error ?? 'unknown error'}`}</Text>
982 ) : (
983 <Box flexDirection="column">
984 <Text wrap="wrap" color="yellow">{`Fetched again by this pane at ${fmtTime(r.at)}, not the bytes WebFetch received: mods only see the tool's output. The page may have changed, or answer this request differently.`}</Text>
985 {field('Status', `HTTP ${r.status ?? '?'}`, r.status !== undefined && r.status < 400 ? undefined : 'red')}
986 {field('Content type', r.headers?.['content-type'] ?? '—')}
987 {field('Length', `${r.length ?? 0} chars${(r.length ?? 0) > RAW_MAX ? ` — first ${RAW_MAX} shown` : ''}`)}
988 {rawPages > 1 ? (
989 <Box flexDirection="row" columnGap={1} marginTop={1}>
990 <Button key="raw-prev" plain hotkey="k" dimColor={rawPage === 0} label="previous part" onPress={() => setView($, x => ({ ...x, rawPage: Math.max(0, rawPage - 1) }))} />
991 <Text dimColor>{`part ${rawPage + 1}/${rawPages}`}</Text>
992 <Button key="raw-next" plain hotkey="j" dimColor={rawPage >= rawPages - 1} label="next part" onPress={() => setView($, x => ({ ...x, rawPage: Math.min(rawPages - 1, rawPage + 1) }))} />
993 </Box>
994 ) : null}
995 <Text> </Text>
996 {body === '' ? <Text dimColor>(empty body)</Text> : chunks(pageText, RAW_CHUNK).map((t, i) => <Text key={`raw-${rawPage}-${i}`} wrap="wrap">{t}</Text>)}
997 </Box>
998 )}
999 </Box>
1000 )
1001 return (
1002 <Box flexDirection="column">
1003 {header}
1004 <Box flexDirection="row" columnGap={1} marginTop={1}>
1005 <Button key="back" plain hotkey="b" label="back" onPress={() => goBack($)} />
1006 <Button key="prev" plain hotkey="p" dimColor={idx <= 0} label="newer" onPress={() => go(list[idx - 1])} />
1007 <Button key="next" plain hotkey="n" dimColor={idx < 0 || idx >= list.length - 1} label="older" onPress={() => go(list[idx + 1])} />
1008 <Button key="same-prompt" plain hotkey="o" label="all from this prompt" onPress={() => navigate($, x => ({ ...x, mode: 'list', promptId: c.promptId ?? '', host: undefined, filter: 'all', page: 0 }))} />
1009 <Button key="same-host" plain hotkey="t" label="all to this host" onPress={() => navigate($, x => ({ ...x, mode: 'list', host: c.host, promptId: undefined, filter: 'all', page: 0 }))} />
1010 <Button key="copy" plain hotkey="c" label="copy" onPress={press => void $.ui.copy({ text: copyText, surface: press.surface })} />
1011 </Box>
1012 <Text> </Text>
1013 <Text wrap="truncate-end">
1014 <Text color={mark.color}>{mark.glyph} </Text>
1015 <Text color={KIND_COLOR[c.kind]} bold>{KIND_LABEL[c.kind]} </Text>
1016 <Text bold>{c.host}</Text>
1017 {fresh.has(c.host) ? <Text color="green" bold>{' ★ NEW HOST'}</Text> : null}
1018 <Text dimColor>{` #${c.seq}`}</Text>
1019 </Text>
1020 {c.kind === 'share' ? (
1021 <Box flexDirection="column" borderStyle="round" borderColor={KIND_COLOR.share} paddingX={1} marginY={1}>
1022 <Text color={KIND_COLOR.share} bold>⇄ File-share access — not a web or API connection</Text>
1023 <Text wrap="wrap">{`The path lies on the network file system ${c.url ?? c.host}. The operating system reaches it over ${c.protocol}, opening or reusing a session to ${c.host} for the file access itself; nothing goes to a web server or API.`}</Text>
1024 <Text wrap="wrap" dimColor>{'Duration and size are those of the tool call; the file-system session may outlive it, be shared with other programs, and serve reads from a local cache.'}</Text>
1025 </Box>
1026 ) : null}
1027 {field('Status', `${c.status}${c.statusText ? ` — ${c.statusText}` : ''}`, mark.color)}
1028 {c.url ? field('URL', c.url) : null}
1029 {field('Protocol', c.protocol)}
1030 {field('Evidence', `${EVIDENCE_TEXT[c.confidence]} — ${c.reason}`, c.confidence === 'inferred' || c.confidence === 'uncertain' ? 'yellow' : c.confidence === 'local' ? 'gray' : undefined)}
1031 {field('Started', `${fmtTime(c.startedAt)}`)}
1032 {field('Duration', c.endedAt === undefined ? 'still open' : fmtDur(durOf(c)))}
1033 {c.bytes !== undefined ? field(c.kind === 'model' ? 'Streamed out' : 'Size', c.kind === 'model' ? `${c.bytes} chars` : fmtBytes(c.bytes)) : null}
1034 {field('Made by', c.source + (c.toolUseId ? ` (${c.toolUseId})` : ''))}
1035 {c.agentId ? field('Subagent', c.agentId) : null}
1036 {parent !== undefined ? (
1037 <Box flexDirection="column" marginTop={1}>
1038 <Text bold>Caused by</Text>
1039 <Text wrap="wrap" dimColor>{`The ${parent.source} call whose processes opened this connection; the engine reports the call, the poller saw the connection.`}</Text>
1040 <Button key="parent" plain hotkey="u" label={`↑ ${rowLabel(parent)}`} onPress={() => navigate($, x => ({ ...x, selected: parent.id }))} />
1041 </Box>
1042 ) : null}
1043 {seen.length > 0 ? (
1044 <Box flexDirection="column" marginTop={1}>
1045 <Text bold>{`Observed connections of this call (${seen.length})`}</Text>
1046 <Text wrap="wrap" dimColor>{"What this call's processes connected to, as the poller saw it in the TCP table."}</Text>
1047 {seen.slice(0, 20).map((x, i) => (
1048 <Button key={`seen-${x.id}`} plain hotkey={i < 9 ? String(i + 1) : undefined} label={`↳ ${rowLabel(x)}`} onPress={() => navigate($, v2 => ({ ...v2, selected: x.id }))} />
1049 ))}
1050 </Box>
1051 ) : null}
1052 <Text> </Text>
1053 <Text bold>Origin</Text>
1054 {field(promptLabel(c.promptId), p ? `${fmtTime(p.at)} ${trunc(oneLine(p.text), 600)}` : 'before any prompt (session start)')}
1055 {c.command ? field(c.kind === 'model' ? 'Request' : 'Command', trunc(c.command, 800)) : null}
1056 <Text> </Text>
1057 <Text bold>Details</Text>
1058 {c.details.filter(d => !(isFetch && d.key === 'Response')).map(d => field(d.key, d.value))}
1059 {isFetch ? responseSection : null}
1060 {siblings.length > 0 ? (
1061 <Box flexDirection="column" marginTop={1}>
1062 <Text bold>{`Other connections from ${promptLabel(c.promptId)} (${siblings.length})`}</Text>
1063 {siblings.slice(-8).reverse().map(s => (
1064 <Button key={`sib-${s.id}`} plain dimColor label={trunc(`#${s.seq} ${KIND_LABEL[s.kind]} ${s.host} ${s.statusText ?? ''}`, width - 2)} onPress={() => navigate($, x => ({ ...x, selected: s.id }))} />
1065 ))}
1066 </Box>
1067 ) : null}
1068 </Box>
1069 )
1070 }
1071
1072 // ---- by prompt ----
1073 if (v.mode === 'prompts') {
1074 const byPrompt = new Map<string, NetConn[]>()
1075 for (const c of all) {
1076 const k = c.promptId ?? ''
1077 byPrompt.set(k, [...(byPrompt.get(k) ?? []), c])
1078 }
1079 const items = [...ps].reverse().map(p => ({ p, cs: byPrompt.get(p.id) ?? [] }))
1080 if (byPrompt.has('')) items.push({ p: { id: '', seq: 0, kind: 'other', text: '(before any prompt)', at: 0 }, cs: byPrompt.get('') ?? [] })
1081 const room = rows - 7
1082 const pages = Math.max(1, Math.ceil(items.length / room))
1083 const page = Math.min(v.page, pages - 1)
1084 return (
1085 <Box flexDirection="column">
1086 {header}
1087 <Text dimColor>{'Press a prompt to list its connections.'}</Text>
1088 {items.length === 0 ? <Text dimColor>No prompts yet.</Text> : null}
1089 {items.slice(page * room, page * room + room).map(({ p, cs }) => {
1090 const kinds = (['model', 'web', 'shell', 'mcp', 'service', 'share', 'local'] as const).map(k => [k, cs.filter(c => c.kind === k).length] as const).filter(([, n]) => n > 0)
1091 const tag = p.id === '' ? '—' : `${p.kind === 'command' ? 'C' : 'P'}${p.seq}`
1092 const netCount = cs.filter(c => c.kind !== 'local').length
1093 const summary = kinds.map(([k, n]) => `${n} ${KIND_LABEL[k].toLowerCase()}`).join(', ') || 'no tool calls'
1094 const label = `${tag.padEnd(4)} ${p.at ? fmtTime(p.at) : ' '} ${String(netCount).padStart(3)} net ${summary.padEnd(34)} ${oneLine(p.text)}`
1095 return (
1096 <Button key={`p-${p.id || 'none'}`} plain dimColor={netCount === 0} label={trunc(label, width - 1)} onPress={() => navigate($, x => ({ ...x, mode: 'list', promptId: p.id, host: undefined, filter: 'all', page: 0 }))} />
1097 )
1098 })}
1099 {pager(page, pages)}
1100 </Box>
1101 )
1102 }
1103
1104 // ---- by host ----
1105 if (v.mode === 'hosts') {
1106 const agg = new Map<string, { n: number; err: number; bytes: number; last: number; kinds: Set<NetKind>; prompts: Set<string> }>()
1107 for (const c of net) {
1108 const a = agg.get(c.host) ?? { n: 0, err: 0, bytes: 0, last: 0, kinds: new Set(), prompts: new Set() }
1109 a.n += 1
1110 if (c.status === 'error') a.err += 1
1111 a.bytes += c.kind === 'model' ? 0 : c.bytes ?? 0
1112 a.last = Math.max(a.last, c.startedAt)
1113 a.kinds.add(c.kind)
1114 if (c.promptId) a.prompts.add(c.promptId)
1115 agg.set(c.host, a)
1116 }
1117 // Real hosts first; then the rows whose command named no destination.
1118 const byCount = (x: [string, { n: number }], y: [string, { n: number }]) => y[1].n - x[1].n
1119 const entries = [...agg.entries()]
1120 const sessionItems = [...entries.filter(([h]) => !PLACEHOLDER_HOSTS.has(h)).sort(byCount), ...entries.filter(([h]) => PLACEHOLDER_HOSTS.has(h)).sort(byCount)]
1121 const hostSet = v.hostSet ?? 'session'
1122 // `known` adds the hosts earlier sessions used, newest first, after the ones this session reached.
1123 const stored = hostSet === 'known' ? await $.store.get(KNOWN_KEY) : undefined
1124 const earlier = (Array.isArray(stored) ? stored.filter((h): h is string => typeof h === 'string') : []).reverse().filter(h => !agg.has(h))
1125 const items: [string, (typeof entries)[number][1] | undefined][] =
1126 hostSet === 'new'
1127 ? sessionItems.filter(([h]) => fresh.has(h))
1128 : hostSet === 'known'
1129 ? [...sessionItems, ...earlier.map((h): [string, undefined] => [h, undefined])]
1130 : sessionItems
1131 const mnt = await read($, mounts)
1132 const mountWord = mnt.platform === 'windows' ? 'mapped drives' : 'network mounts'
1133 const room = rows - 9
1134 const pages = Math.max(1, Math.ceil(items.length / room))
1135 const page = Math.min(v.page, pages - 1)
1136 return (
1137 <Box flexDirection="column">
1138 {header}
1139 <Box flexDirection="row" columnGap={1}>
1140 <Text color={KIND_COLOR.share} wrap="truncate-end">
1141 {mnt.isLoaded !== true
1142 ? mnt.error !== undefined
1143 ? `Could not list ${mountWord}: ${mnt.error}.`
1144 : `Looking up ${mountWord}…`
1145 : mnt.list.length === 0
1146 ? `No ${mountWord}.`
1147 : trunc(`⇄ ${mountWord[0]!.toUpperCase()}${mountWord.slice(1)}: ${mnt.list.map(mountLine).join(' · ')}`, width - 22)}
1148 </Text>
1149 <Button key="refresh-mounts" plain hotkey="r" label={`refresh ${mnt.platform === 'windows' ? 'drives' : 'mounts'}`} onPress={() => void loadMounts($)} />
1150 </Box>
1151 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
1152 <Button key="hs-session" plain hotkey="i" dimColor={hostSet !== 'session'} label="this session" onPress={() => setView($, x => ({ ...x, hostSet: 'session', page: 0 }))} />
1153 <Button key="hs-new" plain hotkey="e" dimColor={hostSet !== 'new'} label={`new in this session ${nNew}`} onPress={() => setView($, x => ({ ...x, hostSet: 'new', page: 0 }))} />
1154 <Button key="hs-known" plain hotkey="y" dimColor={hostSet !== 'known'} label="all known" onPress={() => setView($, x => ({ ...x, hostSet: 'known', page: 0 }))} />
1155 </Box>
1156 <Text dimColor>{`${'count'.padStart(5)} ${'last'.padEnd(8)} ${'prompts'.padStart(7)} ${'kinds'.padEnd(14)} host`}</Text>
1157 {items.length === 0 ? <Text dimColor>{hostSet === 'new' ? 'No new hosts in this session.' : hostSet === 'known' ? 'No hosts known yet.' : 'No connections yet.'}</Text> : null}
1158 {items.slice(page * room, page * room + room).map(([host, a], i, shown) => {
1159 if (a === undefined) return <Text key={`hr-${host}`} dimColor wrap="truncate-end">{`${'—'.padStart(5)} ${'earlier'.padEnd(8)} ${'—'.padStart(7)} ${''.padEnd(14)} ${host}`}</Text>
1160 const kinds = [...a.kinds].map(k => KIND_LABEL[k].toLowerCase()).join(',')
1161 const label = `${String(a.n).padStart(5)} ${fmtTime(a.last)} ${String(a.prompts.size).padStart(7)} ${kinds.padEnd(14)} ${fresh.has(host) ? '★ NEW ' : ''}${host}${a.err ? ` (${a.err} failed)` : ''}`
1162 const placeholder = PLACEHOLDER_HOSTS.has(host)
1163 const firstPlaceholder = placeholder && (i === 0 || !PLACEHOLDER_HOSTS.has(shown[i - 1]![0]))
1164 return (
1165 <Box key={`hr-${host}`} flexDirection="column">
1166 {firstPlaceholder ? <Text dimColor>{'No host named (the command text does not say where it connects):'}</Text> : null}
1167 <Button key={`h-${host}`} plain dimColor={placeholder} label={trunc(label, width - 1)} onPress={() => navigate($, x => ({ ...x, mode: 'list', host, promptId: undefined, filter: 'all', page: 0 }))} />
1168 </Box>
1169 )
1170 })}
1171 {pager(page, pages)}
1172 </Box>
1173 )
1174 }
1175
1176 // ---- list ----
1177 const list = scoped(all, v, networked)
1178 const room = rows - 10
1179 const pages = Math.max(1, Math.ceil(list.length / room))
1180 const page = Math.min(v.page, pages - 1)
1181 const scopeText =
1182 v.promptId !== undefined
1183 ? `${promptLabel(v.promptId || undefined)}: ${oneLine(promptById.get(v.promptId)?.text ?? '(before any prompt)')}`
1184 : v.host !== undefined
1185 ? `host ${v.host}`
1186 : undefined
1187 const filters: readonly ['all' | NetKind, string, string][] = [
1188 ['all', 'a', 'all'],
1189 ['model', 'm', 'model'],
1190 ['web', 'w', 'web'],
1191 ['shell', 's', 'shell'],
1192 ['mcp', 'x', 'mcp'],
1193 ['service', 'v', 'service'],
1194 ['share', 'f', 'share'],
1195 ['local', 'k', 'local'],
1196 ]
1197 const hostW = Math.max(12, Math.min(34, Math.floor(width * 0.3)))
1198 return (
1199 <Box flexDirection="column">
1200 {header}hooks/detect.ts 347 lines1// Reads a shell command for the network endpoints it is likely to reach.
2// Nothing here sees a socket: it is the command's text, so every target is
3// `inferred` and says which part of the command pointed at the network.
4
5import type { NetMount } from '../types'
6
7export type Target = {
8 /** Set when this target is another kind than the call's other targets (a share). */
9 kind?: 'share'
10 host: string
11 url?: string
12 protocol: string
13 reason: string
14}
15
16const URL_RE = /\b((?:https?|wss?|ftp|sftp|ssh|git):\/\/[^\s'"`<>|;)\]]+)/gi
17const SCP_RE = /(?:^|\s)(?:[\w.-]+@)?([a-z0-9-]+(?:\.[a-z0-9-]+)+):(?!\/\/)[\w~./-]+/gi
18
19type Rule = {
20 test: RegExp
21 host: string | ((m: RegExpMatchArray) => string | undefined)
22 protocol: string
23 reason: string
24 /** Only when no URL in the command already names the endpoint. */
25 ifNoUrl?: true
26}
27
28const RULES: readonly Rule[] = [
29 { test: /\b(?:npm|pnpm|yarn)\s+(?:i|install|add|ci|update|upgrade|up|publish|view|info|outdated|audit|dlx|create|exec)\b/i, host: 'registry.npmjs.org', protocol: 'HTTPS', reason: 'npm-family package command' },
30 { test: /\b(?:npx|bunx|pnpx)\s/i, host: 'registry.npmjs.org', protocol: 'HTTPS', reason: 'package runner may download the package' },
31 { test: /\bbun\s+(?:i|install|add|update|x)\b/i, host: 'registry.npmjs.org', protocol: 'HTTPS', reason: 'bun package command' },
32 { test: /\b(?:pip3?|python3?\s+-m\s+pip)\s+(?:install|download)\b|\buv\s+(?:pip\s+install|add|sync|lock)\b|\bpoetry\s+(?:install|add|update|lock)\b|\bpipx\s+(?:install|run)\b/i, host: 'pypi.org', protocol: 'HTTPS', reason: 'Python package command' },
33 { test: /\bcargo\s+(?:build|install|fetch|update|add|run|test|check|publish)\b/i, host: 'index.crates.io', protocol: 'HTTPS', reason: 'cargo may fetch crates' },
34 { test: /\bgo\s+(?:get|install|mod\s+(?:download|tidy))\b/i, host: 'proxy.golang.org', protocol: 'HTTPS', reason: 'Go module command' },
35 { test: /\b(?:gem\s+install|bundle\s+(?:install|update))\b/i, host: 'rubygems.org', protocol: 'HTTPS', reason: 'Ruby gem command' },
36 { test: /\b(?:dotnet\s+(?:restore|add\s+\S+\s+package|tool\s+install)|nuget\s+install)\b/i, host: 'api.nuget.org', protocol: 'HTTPS', reason: '.NET package command' },
37 { test: /\b(?:mvn|gradlew?)\b/i, host: 'repo.maven.apache.org', protocol: 'HTTPS', reason: 'JVM build may resolve dependencies' },
38 { test: /\b(?:docker|podman)\s+(?:pull|push|login|run|build|compose\s+(?:pull|up|build))\b/i, host: 'registry-1.docker.io', protocol: 'HTTPS', reason: 'container registry command' },
39 { test: /\bgh\s+[a-z]/i, host: 'api.github.com', protocol: 'HTTPS', reason: 'GitHub CLI' },
40 { test: /\bgit\s+(?:clone|fetch|pull|push|ls-remote|remote\s+update|submodule\s+update)\b/i, host: 'git remote', protocol: 'git/HTTPS/SSH', reason: 'git command talks to a remote', ifNoUrl: true },
41 { test: /\b(?:winget)\s+(?:install|upgrade|update|search|source)\b/i, host: 'cdn.winget.microsoft.com', protocol: 'HTTPS', reason: 'winget package command' },
42 { test: /\b(?:choco)\s+(?:install|upgrade|search)\b/i, host: 'community.chocolatey.org', protocol: 'HTTPS', reason: 'Chocolatey package command' },
43 { test: /\bscoop\s+(?:install|update|search)\b/i, host: 'github.com', protocol: 'HTTPS', reason: 'Scoop package command' },
44 { test: /\bbrew\s+(?:install|update|upgrade|tap)\b/i, host: 'formulae.brew.sh', protocol: 'HTTPS', reason: 'Homebrew command' },
45 { test: /\b(?:apt|apt-get|dnf|yum|apk)\s+(?:install|update|upgrade|add)\b/i, host: 'system package mirror', protocol: 'HTTP(S)', reason: 'system package manager' },
46 { test: /(?:^|[;&|(]|\bsudo\s|\btime\s)\s*(?:ssh|scp|sftp|rsync|mosh)\s+(?:-\S+\s+)*(?:[\w.-]+@)?([a-z0-9][\w.-]*)/i, host: m => m[1], protocol: 'SSH', reason: 'remote shell / copy command' },
47 { test: /(?:^|[;&|(]|\bsudo\s|\btime\s)\s*(?:ping|nslookup|dig|host|traceroute|tracert|telnet|nc|ncat|whois)\s+(?:-\S+\s+)*([a-z0-9][\w.-]*)/i, host: m => m[1], protocol: 'ICMP/DNS/TCP', reason: 'network diagnostic command' },
48 { test: /\b(?:Test-NetConnection|tnc|Resolve-DnsName|Test-Connection)\s+(?:-\w+\s+)*([a-z0-9][\w.-]*)/i, host: m => m[1], protocol: 'TCP/DNS/ICMP', reason: 'PowerShell network cmdlet' },
49 { test: /\b(?:curl|wget|Invoke-WebRequest|iwr|Invoke-RestMethod|irm|http|https|xh)\b/i, host: 'unknown host', protocol: 'HTTP(S)', reason: 'HTTP client in command', ifNoUrl: true },
50 { test: /\bclaude\s+plugin\s+(?:install|marketplace\s+(?:add|update))\b/i, host: 'github.com', protocol: 'HTTPS', reason: 'plugin install fetches from a marketplace', ifNoUrl: true },
51]
52
53/**
54 * Host names that stand for a destination the command text does not name:
55 * kept on the row, but not counted or listed as hosts.
56 */
57export const PLACEHOLDER_HOSTS: ReadonlySet<string> = new Set(['unknown (script)', 'unknown host', 'git remote', 'system package mirror', 'local'])
58
59export function hostOf(url: string): string {
60 try {
61 return new URL(url).host || url
62 } catch {
63 return url
64 }
65}
66
67export function protocolOf(url: string): string {
68 const scheme = url.split(':')[0]?.toLowerCase() ?? ''
69 return scheme === '' ? 'HTTP(S)' : scheme.toUpperCase()
70}
71
72// The body of a heredoc is data being written, not a command: its words and URLs connect to nothing.
73const HEREDOC_RE = /(<<-?\s*(['"]?)(\w+)\2[^\n]*\n)[\s\S]*?\n[ \t]*\3(?=\s|$)/g
74
75export function detectShell(raw: string): Target[] {
76 const command = raw.replace(HEREDOC_RE, '$1')
77 const found = new Map<string, Target>()
78 const add = (t: Target) => {
79 const was = found.get(t.host)
80 if (was === undefined) found.set(t.host, t)
81 else if (!was.reason.includes(t.reason)) found.set(t.host, { ...was, reason: `${was.reason}; ${t.reason}` })
82 }
83
84 for (const m of command.matchAll(URL_RE)) {
85 const url = (m[1] ?? "").replace(/[.,]+$/, '')
86 add({ host: hostOf(url), url, protocol: protocolOf(url), reason: 'URL in command' })
87 }
88 if (/\bgit\b/i.test(command)) {
89 for (const m of command.matchAll(SCP_RE)) {
90 add({ host: m[1] ?? "", protocol: 'SSH', reason: 'git SSH remote in command' })
91 }
92 }
93 const hasUrl = found.size > 0
94 for (const rule of RULES) {
95 if (rule.ifNoUrl && hasUrl) continue
96 const m = command.match(rule.test)
97 if (m === null) continue
98 const host = typeof rule.host === 'string' ? rule.host : rule.host(m)
99 if (host === undefined || host === '' || /^-/.test(host)) continue
100 add({ host, protocol: rule.protocol, reason: rule.reason })
101 }
102
103 return [...found.values()]
104}
105
106// Commands that run code the text does not show: a script, an interpreter, a
107// build or test runner. Whatever they connect to is invisible from here.
108const SCRIPT_RULES: readonly [RegExp, string][] = [
109 [/(?:^|[\s;&|(])(?:python3?|py|node|deno|bun|ruby|perl|php|java|pwsh|powershell|bash|sh|zsh|Rscript|julia)(?:\.exe)?\s+(?!-(?:-?version|V|v|h|-help)\b)\S/i, 'starts an interpreter'],
110 [/(?:^|[;&|(])\s*(?:&\s*|\.\s+|source\s+|call\s+)?["']?[\w.:\\/-]*\.(?:sh|ps1|py|js|mjs|cjs|ts|rb|pl|bat|cmd|exe)\b/i, 'runs a script or program file'],
111 [/\b(?:go|cargo|dotnet)\s+(?:run|test)\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run|test|start|exec)\b|\b(?:make|cmake|pytest|jest|vitest|mocha|tox|nox)\b/i, 'runs a build, test or task runner'],
112 [/\b(?:Start-Process|Invoke-Expression|iex|Invoke-Command|icm|Start-Job)\b/i, 'starts code PowerShell cannot show'],
113]
114
115/** A target for a command that may connect through code its text does not show. */
116export function detectUncertain(command: string): Target | undefined {
117 const reasons = SCRIPT_RULES.filter(([re]) => re.test(command)).map(([, why]) => why)
118 if (reasons.length === 0) return undefined
119 return { host: 'unknown (script)', protocol: 'unknown', reason: `${reasons.join('; ')}: connections it makes are not visible` }
120}
121
122// UNC paths (`\\server\share\…`, or `//server/share/…` as a POSIX shell spells
123// one): file access that Windows carries over the network, SMB by default,
124// WebDAV for `\\server@SSL\…`. Not an HTTP or API connection.
125const UNC_RE = /(?:^|[\s"'=(,;|&>])(?:\\\\|\/\/)([A-Za-z0-9][\w.-]*)(@SSL)?(?:@(\d+))?[\\/]([^\\/\s"'|;&<>]+)/g
126
127export function detectShare(text: string): Target[] {
128 const found = new Map<string, Target>()
129 for (const m of text.matchAll(UNC_RE)) {
130 const server = m[1] ?? ''
131 const share = m[4] ?? ''
132 if (server === '' || server === '.' || server === '?') continue // \\.\ and \\?\ are local device paths
133 const isDav = m[2] !== undefined || m[3] !== undefined
134 const key = `${server.toLowerCase()}/${share.toLowerCase()}`
135 if (found.has(key)) continue
136 found.set(key, {
137 host: server,
138 url: `\\\\${server}\\${share}`,
139 protocol: isDav ? `WebDAV over HTTP${m[2] ? 'S' : ''}${m[3] ? ` (port ${m[3]})` : ''}` : 'SMB (TCP 445)',
140 reason: `UNC path to share "${share}" on ${server}`,
141 })
142 }
143 return [...found.values()]
144}
145
146// ---------- network drives and mounts ----------
147
148const PROTOCOL_BY_FS: readonly [RegExp, string][] = [
149 [/^(?:cifs|smb3?|smbfs)$/i, 'SMB (TCP 445)'],
150 [/^nfs\d?$/i, 'NFS (TCP 2049)'],
151 [/^afpfs$/i, 'AFP (TCP 548)'],
152 [/^(?:webdav|davfs|fuse\.davfs2?)$/i, 'WebDAV over HTTP(S)'],
153 [/^(?:fuse\.)?sshfs$/i, 'SFTP over SSH (TCP 22)'],
154 [/^(?:ceph|fuse\.ceph(?:-fuse)?)$/i, 'Ceph'],
155 [/^(?:glusterfs|fuse\.glusterfs)$/i, 'GlusterFS'],
156 [/^fuse\.rclone$/i, 'rclone (remote storage)'],
157 [/^9p$/i, '9P'],
158]
159// FUSE types that mount anything; network only when the source looks remote.
160const GENERIC_FUSE = /^(?:macfuse|osxfuse|fuse)$/i
161const GVFS = /^fuse\.gvfsd-fuse$/i
162
163/** The host a mount source names: `//user@server/share`, `server:/export`, `user@host:/p`, a URL. */
164export function hostOfSource(source: string): string {
165 if (/^[a-z][\w+.-]*:\/\//i.test(source)) return hostOf(source)
166 return source
167 .replace(/^\/\//, '')
168 .replace(/^[^@/]*@/, '')
169 .replace(/[:/].*$/, '')
170 .replace(/;.*$/, '')
171}
172
173/** The mapped network drives of a Windows lookup, as mounts. */
174export function driveMounts(drives: Readonly<Record<string, string>>): NetMount[] {
175 return Object.entries(drives).map(([letter, unc]) => {
176 const server = unc.replace(/^\\\\/, '').split('\\')[0] ?? unc
177 const dav = /@SSL|@\d+/i.test(server)
178 return {
179 root: letter.toUpperCase(),
180 source: unc,
181 host: server.replace(/@.*$/, ''),
182 protocol: dav ? `WebDAV over HTTP${/@SSL/i.test(server) ? 'S' : ''}` : 'SMB (TCP 445)',
183 fsType: 'drive',
184 }
185 })
186}
187
188/**
189 * The network file systems in `mount` output, Linux (`src on /p type T (opts)`)
190 * or macOS (`src on /p (T, opts)`). Local disks, pseudo file systems and `/`
191 * itself are left out.
192 */
193export function parseMountOutput(stdout: string): NetMount[] {
194 const mounts: NetMount[] = []
195 for (const line of stdout.split(/\r?\n/)) {
196 const m = line.match(/^(.+?) on (\/.*?) (?:type (\S+) \(|\(([^,)]+))/)
197 if (m === null) continue
198 const source = m[1] ?? ''
199 const root = (m[2] ?? '').replace(/\/+$/, '') || '/'
200 const fsType = (m[3] ?? m[4] ?? '').trim()
201 if (root === '/') continue
202 if (GVFS.test(fsType)) {
203 mounts.push({ root, source: 'GNOME gvfs', host: '(per share)', protocol: 'gvfs', fsType })
204 continue
205 }
206 const known = PROTOCOL_BY_FS.find(([re]) => re.test(fsType))
207 const remoteFuse = GENERIC_FUSE.test(fsType) && /^[^\s/]+@[^\s:]+:|^[^\s/:]+:\//.test(source)
208 if (known === undefined && !remoteFuse) continue
209 mounts.push({ root, source, host: hostOfSource(source), protocol: known?.[1] ?? 'SFTP/remote (FUSE)', fsType })
210 }
211 return mounts
212}
213
214// gvfs keeps each share in a directory named like `smb-share:server=nas,share=media`.
215const GVFS_PROTOCOL: Record<string, string> = {
216 'smb-share': 'SMB (TCP 445)',
217 sftp: 'SFTP over SSH (TCP 22)',
218 dav: 'WebDAV over HTTP',
219 davs: 'WebDAV over HTTPS',
220 nfs: 'NFS (TCP 2049)',
221 ftp: 'FTP (TCP 21)',
222 'afp-volume': 'AFP (TCP 548)',
223}
224
225function gvfsTarget(mount: NetMount, rest: string): Target | undefined {
226 const dir = rest.split('/')[0] ?? ''
227 const m = dir.match(/^([\w-]+):(.*)$/)
228 if (m === null) return undefined
229 const params = Object.fromEntries((m[2] ?? '').split(',').map(kv => kv.split('=') as [string, string]))
230 const host = params.server ?? params.host
231 if (host === undefined) return undefined
232 return {
233 host,
234 url: `${mount.root}/${dir}`,
235 protocol: GVFS_PROTOCOL[m[1] ?? ''] ?? `gvfs ${m[1]}`,
236 reason: `GNOME gvfs mount ${dir}${params.share ? ` (share ${params.share})` : ''}`,
237 }
238}
239
240// A drive letter in a path: `Z:\…`, `Z:/…`, or `/z/…` as Git Bash spells it.
241const DRIVE_RE = /(?:^|[\s"'=(,;|&>])(?:([A-Za-z]):[\\/]|\/([A-Za-z])\/)/g
242const PATH_START = /[\s"'=(,;|&>]/
243const PATH_END = /[\s"'/|;&<>)]/
244
245/**
246 * Targets for paths on network drives or mounts. A drive root (`Z:`) matches
247 * its letter in any spelling; a mount point matches as a whole path prefix,
248 * spaces included (`/Volumes/Team Share/…`), the longest mount winning.
249 */
250export function detectMounts(text: string, mounts: readonly NetMount[]): Target[] {
251 const found = new Map<string, Target>()
252 const drives = new Map(mounts.filter(m => /^[A-Z]:$/.test(m.root)).map(m => [m.root, m]))
253 if (drives.size > 0) {
254 for (const m of text.matchAll(DRIVE_RE)) {
255 const mount = drives.get(`${(m[1] ?? m[2] ?? '').toUpperCase()}:`)
256 if (mount === undefined || found.has(mount.root)) continue
257 found.set(mount.root, {
258 host: mount.host,
259 url: mount.source,
260 protocol: mount.protocol,
261 reason: `mapped network drive ${mount.root} → ${mount.source}`,
262 })
263 }
264 }
265 const points = mounts.filter(m => m.root.startsWith('/')).sort((a, b) => b.root.length - a.root.length)
266 const claimed: [number, number][] = []
267 for (const mount of points) {
268 for (let at = text.indexOf(mount.root); at !== -1; at = text.indexOf(mount.root, at + 1)) {
269 const end = at + mount.root.length
270 const before = at === 0 ? ' ' : text[at - 1] ?? ' '
271 const after = text[end] ?? ' '
272 if (!PATH_START.test(before) || !PATH_END.test(after)) continue
273 if (claimed.some(([s, e]) => at >= s && at < e)) continue // a longer mount already took it
274 claimed.push([at, end])
275 if (GVFS.test(mount.fsType)) {
276 const t = after === '/' ? gvfsTarget(mount, text.slice(end + 1)) : undefined
277 if (t !== undefined && !found.has(t.url ?? '')) found.set(t.url ?? '', t)
278 continue
279 }
280 if (found.has(mount.root)) continue
281 found.set(mount.root, {
282 host: mount.host,
283 url: mount.source,
284 protocol: mount.protocol,
285 reason: `network mount ${mount.root} (${mount.fsType}) → ${mount.source}`,
286 })
287 }
288 }
289 return [...found.values()]
290}
291
292/** Reads the `Z:|\\server\share` lines the session-start CIM query prints. */
293export function parseCimDrives(stdout: string): Record<string, string> {
294 const drives: Record<string, string> = {}
295 for (const line of stdout.split(/\r?\n/)) {
296 const m = line.trim().match(/^([A-Za-z]:)\|(\\\\.+)$/)
297 if (m?.[1] && m[2]) drives[m[1].toUpperCase()] = m[2].trim()
298 }
299 return drives
300}
301
302/** Reads `net use` output, whatever the display language: a letter, then a UNC path. */
303export function parseNetUse(stdout: string): Record<string, string> {
304 const drives: Record<string, string> = {}
305 for (const line of stdout.split(/\r?\n/)) {
306 const m = line.match(/(?:^|\s)([A-Za-z]:)\s+(\\\\\S+)/)
307 if (m?.[1] && m[2]) drives[m[1].toUpperCase()] = m[2]
308 }
309 return drives
310}
311
312// ---------- secrets ----------
313
314// Credentials a command line or tool input may carry: the pane shows commands
315// in full, so each is masked before it is kept.
316const SECRET_RULES: readonly [RegExp, string][] = [
317 // user:password@ in a URL
318 [/\b([a-z][\w+.-]*:\/\/)[^\s/@:'"]+:[^\s/@'"]+@/gi, '$1***:***@'],
319 // Authorization: Bearer … / Basic … headers
320 [/(\bauthorization\s*[:=]\s*(?:bearer|basic|token|digest)?\s*)[^\s'"]+/gi, '$1***'],
321 [/(\bbearer\s+)[\w.~+/=-]{8,}/gi, '$1***'],
322 // --password x, --token=x, -Password x
323 [/(--?(?:password|passwd|pwd|token|api-?key|secret|client-?secret|auth)(?:=|\s+))("[^"]*"|'[^']*'|[^\s'"]+)/gi, '$1***'],
324 // PASSWORD=x, api_key: "x", access-token=x
325 [/(\b[\w-]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key)\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s&,;'"]+)/gi, '$1***'],
326 // well-known token formats wherever they stand
327 [/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_\w{20,}|sk-(?:ant-)?[\w-]{20,}|xox[abprs]-[\w-]{10,}|AKIA[0-9A-Z]{16}|AIza[\w-]{35}|glpat-[\w-]{20,})\b/g, '***'],
328]
329
330/** `text` with the credentials it carries replaced by `***`. */
331export function redact(text: string): string {
332 return SECRET_RULES.reduce((s, [re, to]) => s.replace(re, to), text)
333}
334
335// ---------- home directories ----------
336
337// A user's home directory in a path, as Windows, Git Bash, macOS and Linux spell it.
338const HOME_RULES: readonly [RegExp, string][] = [
339 [/\b[A-Za-z]:[\\/]Users[\\/][^\\/\s"'<>|:*?]+/gi, '~'],
340 [/(^|[\s"'=(:,;|&>])\/(?:[A-Za-z]\/Users|Users|home)\/[^/\s"'<>|:]+/g, '$1~'],
341]
342
343/** `text` with home directories shown as `~`, so a recorded path does not name the user. */
344export function maskHome(text: string): string {
345 return HOME_RULES.reduce((s, [re, to]) => s.replace(re, to), text)
346}
347hooks/poller.ts 217 lines1/**
2 * The poller: a PowerShell loop that watches the TCP table for connections of
3 * processes Claude Code started (shell calls, scripts, the tools they run) and
4 * writes one JSON line per event. The engine reports a Bash or PowerShell call,
5 * never what its processes connect to; this is how the pane sees it.
6 *
7 * Windows only. The table and the process list come from the Win32 API
8 * (`GetExtendedTcpTable`, a Toolhelp snapshot), a millisecond or two per look,
9 * so the loop looks every 25 ms (about 2% of one core) and catches most
10 * connections a short `curl` opens. Missed: one that opens and closes between
11 * two looks, and a process whose parent already exited (its line to Claude
12 * Code is gone, as for a daemonized child).
13 *
14 * Lines, one JSON object each:
15 * - `ready`: `{ root, rootName }`, the Claude Code process whose descendants it watches
16 * - `open`: `{ key, pid, name, cmd, addr, port, local, top, topStart }`; `top` is
17 * the descendant started by Claude Code itself (a shell call's shell), `topStart`
18 * its start in Unix milliseconds
19 * - `close`: `{ key }`, the connection left the table
20 * - `names`: `{ addr, names }`, the DNS cache's names for an address
21 * - `tick`: nothing happened for a while; lets the reader stop the loop
22 * - `end`: the time limit ran out
23 *
24 * Read from standard input (`-Command 'iex ([Console]::In.ReadToEnd())'`), so
25 * no quoting of a command line or size limit of the environment touches it.
26 * `NETCONN_T0` (Unix ms) is the earliest start of a process it counts,
27 * `NETCONN_MAX_MS` how long it runs, `NETCONN_EVERY_MS` the pause between looks.
28 */
29export const POLL_SCRIPT = String.raw`
30$ErrorActionPreference = 'SilentlyContinue'
31Add-Type -TypeDefinition @'
32using System;
33using System.Collections.Generic;
34using System.Net;
35using System.Runtime.InteropServices;
36public static class NetConnTable {
37 [DllImport("iphlpapi.dll")]
38 static extern uint GetExtendedTcpTable(IntPtr table, ref int size, bool order, int af, int cls, uint reserved);
39 [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
40 struct Entry { public uint size, usage, pid; public IntPtr heap; public uint module, threads, ppid; public int prio; public uint flags; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string exe; }
41 [DllImport("kernel32.dll", SetLastError = true)] static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint pid);
42 [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern bool Process32FirstW(IntPtr h, ref Entry e);
43 [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern bool Process32NextW(IntPtr h, ref Entry e);
44 [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
45 static int Port(int p) { return ((p & 0xFF) << 8) | ((p >> 8) & 0xFF); }
46 static bool Remote(IPAddress a, int port) {
47 if (port == 0 || IPAddress.IsLoopback(a) || a.Equals(IPAddress.Any) || a.Equals(IPAddress.IPv6Any)) return false;
48 if (a.IsIPv4MappedToIPv6 && IPAddress.IsLoopback(a.MapToIPv4())) return false;
49 return true;
50 }
51 // Connections to a remote host, of processes not in skip: "pid|local|remote addr|remote port".
52 public static List<string> Rows(HashSet<int> skip) {
53 var res = new List<string>();
54 foreach (int af in new[] { 2, 23 }) {
55 int size = 0;
56 GetExtendedTcpTable(IntPtr.Zero, ref size, false, af, 5, 0);
57 size += 4096;
58 IntPtr buf = Marshal.AllocHGlobal(size);
59 try {
60 if (GetExtendedTcpTable(buf, ref size, false, af, 5, 0) != 0) continue;
61 int n = Marshal.ReadInt32(buf);
62 for (int i = 0; i < n; i++) {
63 if (af == 2) {
64 IntPtr r = buf + 4 + i * 24;
65 int pid = Marshal.ReadInt32(r, 20), rp = Port(Marshal.ReadInt32(r, 16));
66 if (pid <= 0 || skip.Contains(pid)) continue;
67 var ra = new IPAddress((uint)Marshal.ReadInt32(r, 12));
68 if (!Remote(ra, rp)) continue;
69 res.Add(pid + "|" + new IPAddress((uint)Marshal.ReadInt32(r, 4)) + ":" + Port(Marshal.ReadInt32(r, 8)) + "|" + ra + "|" + rp);
70 } else {
71 IntPtr r = buf + 4 + i * 56;
72 int pid = Marshal.ReadInt32(r, 52), rp = Port(Marshal.ReadInt32(r, 44));
73 if (pid <= 0 || skip.Contains(pid)) continue;
74 byte[] l = new byte[16], rb = new byte[16];
75 Marshal.Copy(r + 24, rb, 0, 16);
76 var ra = new IPAddress(rb);
77 if (!Remote(ra, rp)) continue;
78 Marshal.Copy(r, l, 0, 16);
79 res.Add(pid + "|[" + new IPAddress(l) + "]:" + Port(Marshal.ReadInt32(r, 20)) + "|" + ra + "|" + rp);
80 }
81 }
82 } finally { Marshal.FreeHGlobal(buf); }
83 }
84 return res;
85 }
86 public static Dictionary<int, string[]> Procs() {
87 var res = new Dictionary<int, string[]>();
88 IntPtr h = CreateToolhelp32Snapshot(2, 0);
89 if (h == IntPtr.Zero || h == new IntPtr(-1)) return res;
90 try {
91 var e = new Entry();
92 e.size = (uint)Marshal.SizeOf(typeof(Entry));
93 for (bool ok = Process32FirstW(h, ref e); ok; ok = Process32NextW(h, ref e)) res[(int)e.pid] = new[] { e.ppid.ToString(), e.exe };
94 } finally { CloseHandle(h); }
95 return res;
96 }
97}
98'@
99$t0 = [DateTimeOffset]::FromUnixTimeMilliseconds([int64]$env:NETCONN_T0).LocalDateTime
100$maxMs = [int64]$env:NETCONN_MAX_MS
101$every = [int]$env:NETCONN_EVERY_MS
102if ($every -le 0) { $every = 25 }
103if ($maxMs -le 0) { $maxMs = 600000 }
104$me = $PID
105$procs = [NetConnTable]::Procs()
106$root = [int]$procs[$me][0]
107$x = $root
108for ($hops = 0; $hops -lt 8 -and $procs.ContainsKey($x); $hops++) {
109 if ($procs[$x][1] -like 'claude*') { $root = $x; break }
110 $x = [int]$procs[$x][0]
111}
112if ($env:NETCONN_ROOT) { $root = [int]$env:NETCONN_ROOT }
113$top = @{}
114$skip = New-Object 'System.Collections.Generic.HashSet[int]'
115$fresh = $false
116function TopOf($id) {
117 if ($top.ContainsKey($id)) { return $top[$id] }
118 if (-not $procs.ContainsKey($id) -and -not $script:fresh) { $script:procs = [NetConnTable]::Procs(); $script:fresh = $true }
119 $r = 0
120 if ($procs.ContainsKey($id) -and $id -ne $me) {
121 $started = (Get-Process -Id $id).StartTime
122 if ($started -and $started -ge $t0) {
123 $parent = [int]$procs[$id][0]
124 if ($parent -eq $root) { $r = $id } elseif ($parent -ne $id -and $parent -gt 0) { $r = TopOf $parent }
125 }
126 }
127 $top[$id] = $r
128 if ($r -eq 0) { [void]$skip.Add($id) }
129 return $r
130}
131$watch = [Diagnostics.Stopwatch]::StartNew()
132$lastOut = 0
133function Emit($o) { [Console]::Out.WriteLine(($o | ConvertTo-Json -Compress)); $script:lastOut = $watch.ElapsedMilliseconds }
134Emit @{ ev = 'ready'; root = $root; rootName = $procs[$root][1] }
135$open = @{}
136$pending = @{}
137$lastDns = 0
138while ($watch.ElapsedMilliseconds -lt $maxMs) {
139 $fresh = $false
140 $now = @{}
141 foreach ($row in [NetConnTable]::Rows($skip)) {
142 $f = $row.Split('|')
143 $procId = [int]$f[0]
144 $addr = $f[2]
145 $t = TopOf $procId
146 if ($t -gt 0) {
147 $now[$row] = 1
148 if (-not $open.ContainsKey($row)) {
149 $open[$row] = 1
150 $pending[$addr] = 0
151 $cmd = (Get-CimInstance Win32_Process -Filter ('ProcessId=' + $procId) -Property CommandLine).CommandLine
152 $topStart = ([DateTimeOffset](Get-Process -Id $t).StartTime).ToUnixTimeMilliseconds()
153 Emit @{ ev = 'open'; key = $row; pid = $procId; name = $procs[$procId][1]; cmd = $cmd; addr = $addr; port = [int]$f[3]; local = $f[1]; top = $t; topStart = $topStart }
154 }
155 }
156 }
157 foreach ($k in @($open.Keys)) {
158 if (-not $now.ContainsKey($k)) { $open.Remove($k); Emit @{ ev = 'close'; key = $k } }
159 }
160 if ($pending.Count -gt 0 -and $watch.ElapsedMilliseconds - $lastDns -gt 400) {
161 $lastDns = $watch.ElapsedMilliseconds
162 $cache = @(Get-DnsClientCache)
163 foreach ($a in @($pending.Keys)) {
164 $names = @($cache | Where-Object { $_.Data -eq $a } | ForEach-Object { $_.Entry } | Select-Object -Unique)
165 if ($names.Count -gt 0) { Emit @{ ev = 'names'; addr = $a; names = $names }; $pending.Remove($a) }
166 elseif ($pending[$a] -ge 10) { $pending.Remove($a) }
167 else { $pending[$a]++ }
168 }
169 }
170 if ($watch.ElapsedMilliseconds - $lastOut -gt 500) { Emit @{ ev = 'tick' } }
171 Start-Sleep -Milliseconds $every
172}
173Emit @{ ev = 'end' }
174`
175
176export type PollEvent =
177 | { ev: 'ready'; root: number; rootName?: string }
178 | { ev: 'open'; key: string; pid: number; name?: string; cmd?: string | null; addr: string; port: number; local: string; top: number; topStart: number }
179 | { ev: 'close'; key: string }
180 | { ev: 'names'; addr: string; names: string[] }
181 | { ev: 'tick' }
182 | { ev: 'end' }
183
184/** One line of the poller's output, or undefined for anything else it printed. */
185export function parsePollLine(line: string): PollEvent | undefined {
186 try {
187 const o = JSON.parse(line) as { ev?: unknown }
188 return typeof o === 'object' && o !== null && typeof o.ev === 'string' ? (o as PollEvent) : undefined
189 } catch {
190 return undefined
191 }
192}
193
194const PORTS: Record<number, string> = {
195 21: 'FTP', 22: 'SSH', 25: 'SMTP', 53: 'DNS over TCP', 80: 'HTTP', 110: 'POP3', 143: 'IMAP', 389: 'LDAP',
196 443: 'HTTPS', 445: 'SMB', 465: 'SMTPS', 587: 'SMTP', 636: 'LDAPS', 853: 'DNS over TLS', 993: 'IMAPS',
197 995: 'POP3S', 1433: 'SQL Server', 3306: 'MySQL', 3389: 'RDP', 5432: 'PostgreSQL', 5672: 'AMQP',
198 6379: 'Redis', 8080: 'HTTP (8080)', 8443: 'HTTPS (8443)', 9418: 'git', 27017: 'MongoDB',
199}
200
201/**
202 * A command line with its program named by file name alone
203 * (`"C:\Python\python.exe" x.py` → `python.exe x.py`): the folder a program
204 * lives in says little in a list and often names the user.
205 */
206export function shortCommand(cmd: string): string {
207 const m = cmd.trim().match(/^(?:"([^"]+)"|(\S+))([\s\S]*)$/)
208 if (m === null) return cmd
209 const program = m[1] ?? m[2] ?? ''
210 return `${program.split(/[\\/]/).pop() ?? program}${m[3] ?? ''}`
211}
212
213/** What a remote TCP port usually carries. */
214export function portProtocol(port: number): string {
215 return `${PORTS[port] ?? 'TCP'} (TCP ${port})`
216}
217types/index.d.ts 135 lines1/**
2 * `share`: file access over a UNC path (SMB/WebDAV), network traffic but no API or web request.
3 * `local`: a tool call with no known network access, kept so every prompt's calls are complete. */
4export type NetKind = 'model' | 'web' | 'shell' | 'mcp' | 'service' | 'share' | 'local'
5
6export type NetStatus = 'running' | 'ok' | 'error' | 'denied'
7
8export type NetDetail = { key: string; value: string }
9
10export type NetConn = {
11 id: string
12 seq: number
13 kind: NetKind
14 /** The tool that made it (`WebFetch`, `Bash`, `mcp__x__y`), or `model`. */
15 source: string
16 host: string
17 url?: string
18 protocol: string
19 /**
20 * `observed`: the engine reported it; `inferred`: read off a command;
21 * `uncertain`: the command starts a script or interpreter that may connect;
22 * `local`: no known network access.
23 */
24 confidence: 'observed' | 'inferred' | 'uncertain' | 'local'
25 /** Why it counts as a connection (what in the command pointed at the network). */
26 reason: string
27 startedAt: number
28 endedAt?: number
29 status: NetStatus
30 statusText?: string
31 bytes?: number
32 promptId?: string
33 agentId?: string
34 toolUseId?: string
35 /** The command or tool input that caused it. */
36 command?: string
37 /**
38 * Set on a connection the poller saw in the TCP table: it belongs to the
39 * tool call row with the same `toolUseId`.
40 */
41 seenByPoller?: true
42 details: NetDetail[]
43}
44
45export type NetPrompt = {
46 id: string
47 seq: number
48 kind: 'prompt' | 'command' | 'other'
49 text: string
50 at: number
51}
52
53export type NetView = {
54 mode: 'list' | 'detail' | 'prompts' | 'hosts'
55 selected?: string
56 filter: 'all' | NetKind
57 promptId?: string
58 host?: string
59 page: number
60 /** Whether lists show `local` tool calls; absent means hidden. */
61 showLocal?: boolean
62 /** Which hosts the by-host view lists; absent means those this session reached. */
63 hostSet?: 'session' | 'new' | 'known'
64 /** Which response a WebFetch detail shows: the tool's output (default) or the page's raw body. */
65 response?: 'tool' | 'raw'
66 /** Which part of a raw body the detail shows, from 0. */
67 rawPage?: number
68 /** The views left by drilling in, latest last: what `b` returns to. */
69 back?: NetView[]
70}
71
72/**
73 * A page's raw body, fetched again by the pane on request: a mod never sees
74 * the bytes WebFetch itself received, only the tool's processed output.
75 */
76export type NetRaw = {
77 state: 'loading' | 'ok' | 'error'
78 url: string
79 /** When the pane's own request started, in clock milliseconds. */
80 at: number
81 status?: number
82 headers?: Record<string, string>
83 /** The body as text, cut to the first `RAW_MAX` characters. */
84 text?: string
85 /** The body's full length in characters, before any cut. */
86 length?: number
87 error?: string
88}
89
90/**
91 * A network file system reachable through a local path: a mapped drive on
92 * Windows (`Z:` → `\\server\share`), a mount point on macOS and Linux
93 * (`/Volumes/share` → `//user@server/share`, smbfs).
94 */
95export type NetMount = {
96 /** `Z:` on Windows; the absolute mount point elsewhere. */
97 root: string
98 /** What is mounted: the UNC path, `//server/share`, `server:/export`, a URL. */
99 source: string
100 host: string
101 protocol: string
102 /** The file system type as `mount` names it (`cifs`, `nfs4`, `smbfs`), or `drive`. */
103 fsType: string
104}
105
106export type NetMounts = {
107 /** `unknown` until the first lookup answered. */
108 platform: 'windows' | 'posix' | 'unknown'
109 list: NetMount[]
110 /** When the list was last read, in clock milliseconds. */
111 at: number
112 /** Whether any lookup has answered yet; absent means no. */
113 isLoaded?: boolean
114 /** Why the last lookup failed, when it did. */
115 error?: string
116}
117
118declare module 'claude-code' {
119 interface PluginState {
120 'net-connections': {
121 conns: NetConn[]
122 prompts: NetPrompt[]
123 current: string | null
124 agents: Record<string, string>
125 view: NetView
126 /** Network drives and mounts, read at session start and every 10 minutes. */
127 mounts: NetMounts
128 /** Raw page bodies the pane fetched on request, by connection id. */
129 raw: Record<string, NetRaw>
130 /** Hosts no earlier session had seen, flagged for this session only. */
131 newHosts: string[]
132 }
133 }
134}
135