SLOPSHOPPER

port-pane

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

newpanecommandtoaststatusprocess
v0.1.0MITupdated 2026-10-05vincentlauriat/ModsTools/mods/port-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · port-pane
│ ┃ Ports ✕ › fix the failing auth test and add an audit log call │ ┃ No TCP listener │ ┃ [ Refresh ] ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ports │ ⎿ port-pane: Ports pane opened (no tcp listener). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Ports
No TCP listener [ Refresh ]
README

port-pane

Shows which local TCP ports your processes are listening on (dev servers, watchers, databases) and stops the ones started during the session, safely.

What it does

  • /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).
  • Ports first seen after the session started are marked ● 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.
  • Stop (two-step confirm) sends 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.
  • Before any signal, the pid is checked again against a fresh listing: same pid, same command, still listening.
  • Stop is only offered for your own processes whose port was first seen during this session. Allow stop lifts that rule for a process that was already listening.
  • Never stoppable, Allow stop or not: other users' processes, pids below 100, and a short deny list of system and desktop apps (launchd, ControlCenter, rapportd, Spotify, browsers…).
  • Status line 🔌 N dev ports counts the ports opened during this session. It refreshes after main turns, deferred, with a 5 s timeout.

Commands

CommandEffect
/portsOpens the pane (refreshes the listing)
/ports refreshRefreshes the listing
/ports closeCloses the pane

Options

FieldDefaultEffect
statusLinetrueShow 🔌 N dev ports and refresh it after each main turn

Install

claude --plugin-dir /path/to/ModsTools/mods/port-pane

Limits

  • The plugin API gives a background Bash call a task id, not a pid. "Started during this session" therefore means "port first seen after the session-start snapshot", not "started by Claude".
  • A process that closes its port but ignores SIGTERM drops out of the list, so Force stop is no longer offered for it.
  • TCP listeners only (no UDP, no Unix sockets). macOS and Linux with lsof.

Develop

claude plugin validate mods/port-pane
claude plugin test mods/port-pane   # 14 tests
Source 3 files
hooks/register.tsx 240 lines
1import { 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}
240
hooks/ports.ts 131 lines
1// 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}
131
types/index.d.ts 28 lines
1export 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