A pane listing the local TCP ports your processes listen on, marking those opened during the session, with two-step Stop and Force stop buttons

Shows which local TCP ports your processes are listening on (dev servers, watchers, databases) and stops the ones started during the session, safely.
/ports opens a pane listing the TCP listeners you own, from lsof -nP -iTCP -sTCP:LISTEN -F pcnL, grouped by process: command, pid, ports, and the process's working folder (lsof -a -p <pids> -d cwd -F n, one call).● and listed first. A snapshot is taken at session start and kept across hot reloads. The key is pid:port, so a server restarted on an old port counts as new.kill <pid> (SIGTERM). After 3 s, if the process is still alive (kill -0), a toast says so and a Force stop button appears, behind its own two-step confirm, which sends kill -9.🔌 N dev ports counts the ports opened during this session. It refreshes after main turns, deferred, with a 5 s timeout.| Command | Effect |
|---|---|
/ports | Opens the pane (refreshes the listing) |
/ports refresh | Refreshes the listing |
/ports close | Closes the pane |
| Field | Default | Effect |
|---|---|---|
statusLine | true | Show 🔌 N dev ports and refresh it after each main turn |
claude --plugin-dir /path/to/ModsTools/mods/port-pane
lsof.claude plugin validate mods/port-pane
claude plugin test mods/port-pane # 14 testshooks/register.tsx 240 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PortRow } from '../types'
5import { UNAVAILABLE, canStop, countLabel, keysOf, newPortCount, parseCwds, parseListeners, rowLabel, statusText, toRows } from './ports'
6import type { Listener } from './ports'
7
8const PANE = 'port-pane'
9const TITLE = 'Ports'
10const LSOF_MS = 5_000
11const KILL_MS = 5_000
12const GRACE_MS = 3_000
13const LISTEN = ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcnL']
14
15const rows = atom({ plugin: 'port-pane', key: 'rows' } as const, [])
16const error = atom({ plugin: 'port-pane', key: 'error' } as const, null)
17const confirm = atom({ plugin: 'port-pane', key: 'confirm' } as const, null)
18const notice = atom({ plugin: 'port-pane', key: 'notice' } as const, null)
19const baseline = atom({ plugin: 'port-pane', key: 'baseline' } as const, null)
20const allowed = atom({ plugin: 'port-pane', key: 'allowed' } as const, [])
21const stubborn = atom({ plugin: 'port-pane', key: 'stubborn' } as const, [])
22
23type Ctx = {
24 showStatus: boolean
25 // The login lsof's L field is compared with: undefined until asked, null when `id -un` failed.
26 user: string | null | undefined
27 cwds: Map<number, string>
28 busy: boolean
29}
30
31type Ran = { exitCode: number; stdout: string } | null
32
33async function run($: EngineInterface, argv: string[], timeoutMs: number): Promise<Ran> {
34 try {
35 const ran = await $.process.run(argv, { timeoutMs })
36 return { exitCode: ran.exitCode, stdout: ran.stdout }
37 } catch {
38 return null
39 }
40}
41
42async function whoami($: EngineInterface, ctx: Ctx): Promise<string | null> {
43 if (ctx.user === undefined) {
44 const ran = await run($, ['id', '-un'], LSOF_MS)
45 ctx.user = ran !== null && ran.exitCode === 0 && ran.stdout.trim() !== '' ? ran.stdout.trim() : null
46 }
47
48 return ctx.user
49}
50
51// The listeners, or null when lsof cannot run. lsof exits 1 when nothing listens.
52async function listen($: EngineInterface): Promise<Listener[] | null> {
53 const ran = await run($, LISTEN, LSOF_MS)
54 if (ran === null) return null
55 // Exit 1 also covers a partial listing (a process gone mid-scan): the fields printed still count.
56 if (ran.exitCode !== 0 && ran.exitCode !== 1) return null
57
58 return parseListeners(ran.stdout)
59}
60
61async function fetchCwds($: EngineInterface, ctx: Ctx, pids: number[]) {
62 if (pids.length === 0) return
63 const ran = await run($, ['lsof', '-a', '-p', pids.join(','), '-d', 'cwd', '-F', 'n'], LSOF_MS)
64 if (ran === null) return
65 for (const [pid, cwd] of parseCwds(ran.stdout)) ctx.cwds.set(pid, cwd)
66}
67
68// One listing: rows, error and (when enabled) the status line. The first listing of the session is the baseline.
69async function sync($: EngineInterface, ctx: Ctx, withCwd: boolean): Promise<PortRow[] | null> {
70 const listeners = await listen($)
71 if (listeners === null) {
72 await update($, error, () => UNAVAILABLE)
73 return null
74 }
75 const user = await whoami($, ctx)
76 const mine = user === null ? listeners : listeners.filter(one => one.login === user)
77 let known = await read($, baseline)
78 if (known === null) {
79 known = mine.flatMap(keysOf)
80 await update($, baseline, () => known)
81 }
82 if (withCwd) await fetchCwds($, ctx, mine.map(one => one.pid))
83 const list = toRows(mine, known, ctx.cwds)
84 await update($, rows, () => list)
85 await update($, error, () => null)
86 if (ctx.showStatus) $.ui.status(statusText(newPortCount(list, user)))
87
88 return list
89}
90
91async function refresh($: EngineInterface, ctx: Ctx, message: string | null = null): Promise<PortRow[] | null> {
92 const list = await sync($, ctx, true)
93 await update($, confirm, () => null)
94 await update($, notice, () => message)
95
96 return list
97}
98
99// A deferred status refresh after a main turn; one at a time.
100async function background($: EngineInterface, ctx: Ctx) {
101 if (ctx.busy) return
102 ctx.busy = true
103 try {
104 await sync($, ctx, false)
105 } finally {
106 ctx.busy = false
107 }
108}
109
110async function say($: EngineInterface, message: string) {
111 await update($, notice, () => message)
112 $.ui.toast(message)
113}
114
115async function checkAlive($: EngineInterface, ctx: Ctx, pid: number, command: string) {
116 const ran = await run($, ['kill', '-0', String(pid)], KILL_MS)
117 if (ran !== null && ran.exitCode === 0) {
118 await update($, stubborn, list => (list.includes(pid) ? list : [...list, pid]))
119 await say($, `${command} (pid ${pid}) still running — Force stop is offered`)
120 return
121 }
122 await update($, stubborn, list => list.filter(one => one !== pid))
123 await sync($, ctx, false)
124}
125
126// The second press of Stop or Force stop: the pid is checked again against a fresh listing before any signal.
127async function signal($: EngineInterface, ctx: Ctx, kind: 'stop' | 'force', pid: number) {
128 const before = (await read($, rows)).find(row => row.pid === pid)
129 const list = await sync($, ctx, false)
130 const row = list?.find(one => one.pid === pid)
131 if (before === undefined || row === undefined || row.command !== before.command) {
132 await say($, `pid ${pid} is no longer listening`)
133 return
134 }
135 const verdict = canStop(row, await whoami($, ctx), await read($, allowed))
136 if (!verdict.ok) {
137 await say($, `Refused to stop ${row.command} (pid ${pid}): ${verdict.reason}`)
138 return
139 }
140 if (kind === 'force' && !(await read($, stubborn)).includes(pid)) return
141 const ran = await run($, kind === 'stop' ? ['kill', String(pid)] : ['kill', '-9', String(pid)], KILL_MS)
142 if (ran === null || ran.exitCode !== 0) {
143 await say($, `kill failed for ${row.command} (pid ${pid})`)
144 return
145 }
146 if (kind === 'stop') {
147 await say($, `Sent SIGTERM to ${row.command} (pid ${pid})`)
148 $.clock.after(GRACE_MS, () => void checkAlive($, ctx, pid, row.command))
149 } else {
150 await update($, stubborn, ids => ids.filter(one => one !== pid))
151 await say($, `Sent SIGKILL to ${row.command} (pid ${pid})`)
152 $.clock.after(1_000, () => void sync($, ctx, false))
153 }
154}
155
156async function press($: EngineInterface, ctx: Ctx, kind: 'stop' | 'force', pid: number) {
157 const key = `${kind}:${pid}`
158 if ((await read($, confirm)) !== key) {
159 await update($, confirm, () => key)
160 return
161 }
162 await update($, confirm, () => null)
163 await signal($, ctx, kind, pid)
164}
165
166async function toggleAllow($: EngineInterface, pid: number) {
167 await update($, confirm, () => null)
168 await update($, allowed, list => (list.includes(pid) ? list.filter(one => one !== pid) : [...list, pid]))
169}
170
171export const register: Register = (on, options) => {
172 const ctx: Ctx = { showStatus: options?.statusLine !== false, user: undefined, cwds: new Map(), busy: false }
173
174 on('session.start', async ($, e, next) => {
175 await $.command.register({ name: 'ports', description: 'Show the local TCP ports your processes listen on, with Stop buttons (refresh | close)' })
176 // The baseline snapshot (kept across hot reloads in $.state) is taken off the start path.
177 $.clock.after(0, () => void background($, ctx))
178
179 return next(e)
180 })
181
182 on('turn.complete', async ($, e, next) => {
183 const done = await next(e)
184 if (ctx.showStatus && e.agentId === undefined) $.clock.after(0, () => void background($, ctx))
185
186 return done
187 })
188
189 on('command.run', { command: 'ports' }, async ($, e) => {
190 const arg = e.args.trim()
191 if (arg === 'close') {
192 await $.ui.close({ id: PANE })
193 return { text: 'Ports pane closed.' }
194 }
195 const list = await refresh($, ctx)
196 await $.ui.open({ id: PANE, title: TITLE })
197 if (list === null) return { text: UNAVAILABLE }
198
199 return { text: `Ports pane ${arg === 'refresh' ? 'refreshed' : 'opened'} (${countLabel(list).toLowerCase()}).` }
200 })
201
202 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
203 const { Box, Button, Text } = $.ui.resolve(e)
204 const list: PortRow[] = await read($, rows)
205 const problem = await read($, error)
206 const pending = await read($, confirm)
207 const message = await read($, notice)
208 const allow = await read($, allowed)
209 const survivors = await read($, stubborn)
210 const user = ctx.user ?? null
211
212 return (
213 <Box flexDirection="column">
214 {problem !== null ? <Text dimColor>{problem}</Text> : <Text bold>{countLabel(list)}</Text>}
215 {message !== null && <Text>{message}</Text>}
216 {list.map(row => {
217 const verdict = canStop(row, user, allow)
218 const stop = `stop:${row.pid}`
219 const force = `force:${row.pid}`
220 return (
221 <Box flexDirection="column">
222 <Text>{rowLabel(row)}</Text>
223 {row.cwd !== null && <Text dimColor>{row.cwd}</Text>}
224 {!verdict.ok && <Text dimColor>{verdict.reason}</Text>}
225 {verdict.ok && <Button key={stop} label={pending === stop ? 'Confirm stop?' : 'Stop'} onPress={() => press($, ctx, 'stop', row.pid)} />}
226 {verdict.ok && survivors.includes(row.pid) && (
227 <Button key={force} label={pending === force ? 'Confirm kill -9?' : 'Force stop'} onPress={() => press($, ctx, 'force', row.pid)} />
228 )}
229 {!row.isNew && (verdict.ok || !verdict.hard) && (
230 <Button key={`allow:${row.pid}`} label={allow.includes(row.pid) ? 'Allowed ✓' : 'Allow stop'} onPress={() => toggleAllow($, row.pid)} />
231 )}
232 </Box>
233 )
234 })}
235 <Button key="refresh" label="Refresh" onPress={() => refresh($, ctx)} />
236 </Box>
237 )
238 })
239}
240hooks/ports.ts 131 lines1// Pure logic of port-pane: reading `lsof -F` field output and deciding which listeners may be stopped.
2
3import type { PortRow } from '../types'
4
5export const UNAVAILABLE = 'lsof unavailable'
6
7export type Listener = { pid: number; command: string; login: string; ports: number[] }
8
9// lsof -F escapes bytes it will not print as \xNN (a space in a command name is \x20) and a backslash as \\.
10export function decode(text: string): string {
11 return text.replace(/\\x([0-9a-fA-F]{2})|\\\\/g, (whole, hex: string | undefined) => (hex === undefined ? '\\' : String.fromCharCode(parseInt(hex, 16))))
12}
13
14// The port of an lsof name field: `*:3000`, `127.0.0.1:8080`, `[::1]:5177`; null for anything else.
15export function portOf(name: string): number | null {
16 const match = /:(\d+)$/.exec(name.trim())
17 if (match === null) return null
18 const port = Number(match[1])
19
20 return port > 0 && port < 65536 ? port : null
21}
22
23// Groups `lsof -nP -iTCP -sTCP:LISTEN -F pcnL` output by process: p=pid, c=command, L=login, n=address.
24// Other field letters (lsof always adds f=fd) are skipped. Sorted by pid, ports ascending and unique.
25export function parseListeners(stdout: string): Listener[] {
26 const byPid = new Map<number, Listener>()
27 let current: Listener | null = null
28 for (const line of stdout.split('\n')) {
29 const field = line[0]
30 const value = line.slice(1)
31 if (field === 'p') {
32 const pid = Number(value)
33 if (!Number.isInteger(pid) || pid <= 0) {
34 current = null
35 continue
36 }
37 current = byPid.get(pid) ?? { pid, command: '', login: '', ports: [] }
38 byPid.set(pid, current)
39 } else if (current === null) continue
40 else if (field === 'c') current.command = decode(value)
41 else if (field === 'L') current.login = decode(value)
42 else if (field === 'n') {
43 const port = portOf(value)
44 if (port !== null && !current.ports.includes(port)) current.ports.push(port)
45 }
46 }
47
48 return [...byPid.values()]
49 .filter(one => one.ports.length > 0)
50 .map(one => ({ ...one, ports: [...one.ports].sort((a, b) => a - b) }))
51 .sort((a, b) => a.pid - b.pid)
52}
53
54// `lsof -a -p <pids> -d cwd -F n` output: the working folder of each pid.
55export function parseCwds(stdout: string): Map<number, string> {
56 const cwds = new Map<number, string>()
57 let pid: number | null = null
58 for (const line of stdout.split('\n')) {
59 if (line.startsWith('p')) pid = Number(line.slice(1))
60 else if (line.startsWith('n') && pid !== null && Number.isInteger(pid)) cwds.set(pid, decode(line.slice(1)))
61 }
62
63 return cwds
64}
65
66export const keysOf = (one: { pid: number; ports: number[] }): string[] => one.ports.map(port => `${one.pid}:${port}`)
67
68// Whether a port of this listener was not listening when the session started.
69export const isNew = (one: { pid: number; ports: number[] }, baseline: readonly string[]): boolean => keysOf(one).some(key => !baseline.includes(key))
70
71export function toRows(listeners: Listener[], baseline: readonly string[], cwds: ReadonlyMap<number, string>): PortRow[] {
72 const rows = listeners.map(one => ({ ...one, cwd: cwds.get(one.pid) ?? null, isNew: isNew(one, baseline) }))
73
74 return [...rows.filter(row => row.isNew), ...rows.filter(row => !row.isNew)]
75}
76
77// System and desktop apps that listen on local ports and are never offered a Stop button.
78export const DENY = new Set([
79 'launchd',
80 'ControlCenter',
81 'rapportd',
82 'sharingd',
83 'identityservicesd',
84 'WindowServer',
85 'loginwindow',
86 'Finder',
87 'Dock',
88 'SystemUIServer',
89 'mDNSResponder',
90 'Spotify',
91 'Dropbox',
92 'OneDrive',
93 'Google Chrome',
94 'Safari',
95 'Slack',
96 'zoom.us',
97])
98
99export type Verdict = { ok: true } | { ok: false; reason: string; hard: boolean }
100
101// Hard refusals (another user, pid < 100, a denied command) always win; the "Allow stop" toggle only
102// lifts the rule that the port must have been first seen during this session.
103export function canStop(row: { pid: number; command: string; login: string; isNew: boolean }, user: string | null, allowed: readonly number[]): Verdict {
104 if (user === null || row.login !== user) return { ok: false, reason: 'owned by another user', hard: true }
105 if (row.pid < 100) return { ok: false, reason: 'system process', hard: true }
106 if (DENY.has(row.command)) return { ok: false, reason: 'system or desktop app', hard: true }
107 if (!row.isNew && !allowed.includes(row.pid)) return { ok: false, reason: 'listening before this session', hard: false }
108
109 return { ok: true }
110}
111
112// The number of ports first seen during this session among the user's listeners.
113export function newPortCount(rows: readonly PortRow[], user: string | null): number {
114 return new Set(rows.filter(row => row.isNew && row.login === user).flatMap(row => row.ports)).size
115}
116
117export const statusText = (count: number): string | undefined => (count === 0 ? undefined : `🔌 ${count} dev port${count === 1 ? '' : 's'}`)
118
119export function rowLabel(row: PortRow): string {
120 const ports = row.ports.map(port => `:${port}`).join(' ')
121
122 return `${row.isNew ? '● ' : ''}${row.command} · pid ${row.pid} · ${ports}`
123}
124
125export function countLabel(rows: readonly PortRow[]): string {
126 const fresh = rows.filter(row => row.isNew).length
127 if (rows.length === 0) return 'No TCP listener'
128
129 return `${rows.length} listening process${rows.length === 1 ? '' : 'es'} · ${fresh} new this session`
130}
131types/index.d.ts 28 lines1export type PortRow = {
2 pid: number
3 command: string
4 login: string
5 ports: number[]
6 cwd: string | null
7 // A port of this pid was first seen after the session started.
8 isNew: boolean
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'port-pane': {
14 rows: PortRow[]
15 error: string | null
16 // `stop:<pid>` or `force:<pid>` while waiting for the second press.
17 confirm: string | null
18 notice: string | null
19 // `pid:port` keys listening when the session started; null until the first listing.
20 baseline: string[] | null
21 // Pids the person explicitly allowed to stop although they were listening before the session.
22 allowed: number[]
23 // Pids still alive 3 s after SIGTERM: Force stop is offered for them.
24 stubborn: number[]
25 }
26 }
27}
28