SLOPSHOPPER

running-servers

A pane listing the servers a project runs, grouped by worktree, found from the ports its files declare

newpanecommandprocesstimer
v0.1.0no licenseupdated 2026-10-12krichardson/claude-mods/plugins/running-servers
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · running-servers
│ ┃ Servers ✕ › fix the failing auth test and add an audit log call │ ┃ 0 listening on this machine updated 2:20:18 │ ┃ Nothing from is running. ⏺ 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 │ │ › /servers │ ⎿ running-servers: Servers pane opened: 0 running for this project │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Servers
0 listening on this machine updated 2:20:18 PM [ Refresh ] Nothing from is running.
README

running-servers

A Claude Code mod: /servers opens a pane listing the servers your project runs, grouped under the worktree each was started from, with its port, name, a http://localhost link for web servers, and a Kill (or Stop, for a container) button that asks for confirmation.

A listening port belongs to the project when:

  • the project's tracked files declare it: compose ports:, package.json scripts (--port, PORT=), Vite/Next/Astro configs, .env*, justfile, Makefile, Procfile, .claude/launch.json;
  • it is a Docker Compose container started from a checkout of the repository; or
  • it is a Node, Bun or Deno process running in a checkout of the repository (any worktree), which catches servers on shifted ports.

Show all (a) lists every listening TCP port on the machine; Refresh (r) rescans the project's files. It uses lsof, ps, git and, when present, docker, so it works on macOS and Linux.

Install

In a Claude Code terminal session:

/plugin install running-servers --marketplace krichardson/claude-mods

Answer y to add the marketplace, then pick the user scope. It loads in every session after that, the desktop app's Code tab included.

Develop

claude plugin validate plugins/running-servers
claude plugin test plugins/running-servers
claude --plugin-dir plugins/running-servers
Source 3 files
hooks/register.tsx 274 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Server, Snapshot } from '../types'
5import type { Declared } from './parse'
6import {
7  PORT_FILES,
8  declaredPorts,
9  describeJs,
10  parseArgs,
11  parseContainers,
12  parseCwds,
13  groupByCheckout,
14  parseListeners,
15} from './parse'
16
17const PANE = 'running-servers'
18const TITLE = 'Servers'
19const REFRESH_MS = 5000
20const snapshot = atom({ plugin: 'running-servers', key: 'snapshot' } as const, {
21  servers: [],
22  at: 0,
23} as Snapshot)
24// The row (pid:port) whose Kill was pressed once and waits for Confirm.
25const armed = atom({ plugin: 'running-servers', key: 'armed' } as const, null as string | null)
26const showAll = atom({ plugin: 'running-servers', key: 'showAll' } as const, false)
27
28// Directory → the git common dir of the checkout it sits in and that checkout's
29// root ('' when none). Every worktree of one repository shares a common dir, so
30// it names the project; the root names the worktree.
31type Checkout = { repo: string; top: string }
32const repoOf = new Map<string, Promise<Checkout>>()
33let projectRepo = ''
34let projectTop = ''
35let home = ''
36// Port → what the project's own files say listens there; read at start and on Refresh.
37let declared = new Map<number, Declared>()
38
39function shorten(path: string) {
40  return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path
41}
42
43function rowKey(s: Server) {
44  return `${s.pid}:${s.port}`
45}
46
47function lookupRepo($: EngineInterface, dir: string): Promise<Checkout> {
48  let known = repoOf.get(dir)
49  if (!known) {
50    known = $.process
51      .run(['git', '-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel'])
52      .then(r => {
53        const [repo = '', top = ''] = r.exitCode === 0 ? r.stdout.trim().split('\n') : []
54        return { repo, top }
55      })
56      .catch(() => ({ repo: '', top: '' }))
57    repoOf.set(dir, known)
58  }
59  return known
60}
61
62async function findProject($: EngineInterface) {
63  const who = await $.process.run(['sh', '-c', 'echo $HOME']).catch(() => undefined)
64  home = who?.stdout.trim() ?? ''
65  const here = await $.process.run(['pwd']).catch(() => undefined)
66  const mine = here ? await lookupRepo($, here.stdout.trim()) : { repo: '', top: '' }
67  projectRepo = mine.repo
68  projectTop = mine.top
69  await scanProject($)
70}
71
72// Reads every tracked file that can declare a port (compose files, package.json
73// scripts, framework configs, .env files, justfile, Makefile, Procfile).
74async function scanProject($: EngineInterface) {
75  if (!projectTop) return
76  const files = await $.process
77    .run(['git', 'ls-files'], { cwd: projectTop })
78    .then(r => r.stdout.split('\n').filter(f => PORT_FILES.test(f)).slice(0, 200))
79    .catch(() => [] as string[])
80  const next = new Map<number, Declared>()
81  for (const file of files) {
82    const text = await $.fs.read(`${projectTop}/${file}`).catch(() => '')
83    for (const d of declaredPorts(file, typeof text === 'string' ? text : '')) {
84      // A bare PORT says less than api_port or a compose service, so those replace it.
85      const known = next.get(d.port)
86      if (!known || (/^port$/i.test(known.name) && !/^port$/i.test(d.name))) next.set(d.port, d)
87    }
88  }
89  declared = next
90}
91
92async function rescan($: EngineInterface) {
93  await scanProject($)
94  await refresh($)
95}
96
97async function refresh($: EngineInterface) {
98  try {
99    const listen = await $.process.run(
100      ['lsof', '-w', '-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcLn'],
101      { timeoutMs: 10000 },
102    )
103    const servers = parseListeners(listen.stdout)
104    if (servers.length > 0) {
105      const pids = [...new Set(servers.map(s => s.pid))].join(',')
106      const cwd = await $.process.run(['lsof', '-w', '-a', '-d', 'cwd', '-p', pids, '-F', 'pn'], {
107        timeoutMs: 10000,
108      })
109      const cwds = parseCwds(cwd.stdout)
110      const ps = await $.process.run(['ps', '-o', 'pid=,args=', '-p', pids], { timeoutMs: 10000 })
111      const args = parseArgs(ps.stdout)
112      for (const s of servers) {
113        s.cwd = cwds.get(s.pid)
114        s.tool = describeJs(s.command, args.get(s.pid) ?? '')
115      }
116    }
117    const docker = await $.process
118      .run([
119        'docker', 'ps', '--format',
120        '{{.Names}}\t{{.Ports}}\t{{.Label "com.docker.compose.project.working_dir"}}\t{{.Label "com.docker.compose.service"}}',
121      ], { timeoutMs: 10000 })
122      .catch(() => undefined)
123    if (docker?.exitCode === 0) {
124      for (const c of parseContainers(docker.stdout)) {
125        for (const s of servers) {
126          if (!c.ports.includes(s.port)) continue
127          s.container = c.name
128          s.cwd = c.dir
129          s.tool = undefined
130          if (c.service) s.role = c.service
131        }
132      }
133    }
134    for (const s of servers) {
135      const d = declared.get(s.port)
136      if (!d) continue
137      s.role ??= d.name
138      s.source = d.source
139    }
140    await Promise.all(
141      servers.map(async s => {
142        const at = s.cwd && s.cwd !== '/' ? await lookupRepo($, s.cwd) : undefined
143        if (at?.top) s.checkout = at.top
144        const inRepo = !!projectRepo && at?.repo === projectRepo
145        s.isProject = !!projectRepo && (!!s.source || (inRepo && (!!s.container || !!s.tool)))
146      }),
147    )
148    const project = projectRepo ? projectRepo.replace(/\/\.git$/, '').split('/').pop() : undefined
149    await update($, snapshot, () => ({ servers, at: Date.now(), project, declared: declared.size }))
150  } catch (err) {
151    await update($, snapshot, prev => ({ ...prev, at: Date.now(), error: String(err) }))
152  }
153}
154
155async function arm($: EngineInterface, key: string | null) {
156  await update($, armed, () => key)
157}
158
159async function toggleAll($: EngineInterface) {
160  await update($, showAll, all => !all)
161}
162
163// A process gets SIGTERM; a container port belongs to Docker's own process,
164// so the container is stopped instead. A refusal shows as the error line.
165async function kill($: EngineInterface, s: Server) {
166  await update($, armed, () => null)
167  const argv = s.container ? ['docker', 'stop', s.container] : ['kill', '-TERM', String(s.pid)]
168  const ran = await $.process.run(argv, { timeoutMs: 30000 }).catch(err => ({
169    exitCode: 1,
170    stderr: String(err),
171  }))
172  await refresh($)
173  if (ran.exitCode !== 0) {
174    await update($, snapshot, prev => ({ ...prev, error: `${argv.join(' ')}: ${ran.stderr.trim()}` }))
175  }
176}
177
178export const register: Register = on => {
179  on('session.start', async ($, e, next) => {
180    await $.command.register({
181      name: 'servers',
182      description: "Show the servers this project runs, found from the ports its files declare",
183    })
184    await findProject($)
185    void refresh($)
186    $.clock.every(REFRESH_MS, () => void refresh($))
187
188    return next(e)
189  })
190
191  on('command.run', { command: 'servers' }, async $ => {
192    await refresh($)
193    await $.ui.open({ id: PANE, title: TITLE })
194    const { servers, project } = await read($, snapshot)
195    const mine = servers.filter(s => s.isProject).length
196
197    return {
198      text: `Servers pane opened: ${mine} running for ${project ?? 'this project'}, ${servers.length} ports listening on the machine.`,
199    }
200  })
201
202  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
203    const { Box, Text, Button, Link } = $.ui.resolve(e)
204    const { servers, at, error, project, declared: declaredCount } = await read($, snapshot)
205    const pending = await read($, armed)
206    const all = (await read($, showAll)) || !project
207    const shown = all ? servers : servers.filter(s => s.isProject)
208    const time = at ? new Date(at).toLocaleTimeString() : 'never'
209
210    return (
211      <Box flexDirection="column">
212        <Box flexDirection="row" gap={1}>
213          <Text bold>
214            {all
215              ? `${servers.length} listening on this machine`
216              : `${shown.length} running for ${project}`}
217          </Text>
218          <Text dimColor>
219            {declaredCount ? `${declaredCount} ports declared · ` : ''}updated {time}
220          </Text>
221          <Button key="refresh" label="Refresh" hotkey="r" onPress={() => rescan($)} />
222          {project && (
223            <Button
224              key="scope"
225              label={all ? `Only ${project}` : `Show all ${servers.length}`}
226              hotkey="a"
227              dimColor
228              onPress={() => toggleAll($)}
229            />
230          )}
231        </Box>
232        {error && <Text color="red">{error}</Text>}
233        {shown.length === 0 && <Text dimColor>Nothing from {project} is running.</Text>}
234        {groupByCheckout(shown, projectTop).map(group => (
235          <Box flexDirection="column" key={`group:${group.checkout}`}>
236            <Text bold color={group.checkout === projectTop ? 'green' : undefined}>
237              {group.checkout ? shorten(group.checkout) : 'Outside any checkout'}
238              {group.checkout === projectTop ? ' (this session)' : ''}
239            </Text>
240            {group.servers.map(s => (
241              <Box flexDirection="row" gap={1} key={rowKey(s)}>
242                <Text color="cyan">{String(s.port).padStart(7)}</Text>
243                <Text bold>{s.role ?? s.container ?? s.tool ?? s.command}</Text>
244                {s.tool && <Link href={`http://localhost:${s.port}`} />}
245                <Text dimColor>
246                  {s.role && (s.container ?? s.tool) ? `${s.container ?? s.tool} · ` : ''}
247                  {s.container ? 'container' : `pid ${s.pid}`}
248                </Text>
249                {pending === rowKey(s) ? (
250                  <>
251                    <Button
252                      key={`confirm:${rowKey(s)}`}
253                      label={s.container ? 'Confirm stop' : 'Confirm kill'}
254                      onPress={() => kill($, s)}
255                    />
256                    <Button key={`cancel:${rowKey(s)}`} label="Cancel" dimColor onPress={() => arm($, null)} />
257                  </>
258                ) : (
259                  <Button
260                    key={`kill:${rowKey(s)}`}
261                    label={s.container ? 'Stop' : 'Kill'}
262                    dimColor
263                    onPress={() => arm($, rowKey(s))}
264                  />
265                )}
266              </Box>
267            ))}
268          </Box>
269        ))}
270      </Box>
271    )
272  })
273}
274
hooks/parse.ts 195 lines
1import type { Server } from '../types'
2
3// Parses `lsof -nP -iTCP -sTCP:LISTEN -F pcLn` field output: one field per
4// line, `p` opening a process, `c`/`L` its command and user, `n` each socket.
5export function parseListeners(out: string): Server[] {
6  const seen = new Map<string, Server>()
7  let pid = 0
8  let command = ''
9  let user = ''
10  for (const line of out.split('\n')) {
11    const tag = line[0]
12    const value = line.slice(1)
13    if (tag === 'p') pid = Number(value)
14    else if (tag === 'c') command = value
15    else if (tag === 'L') user = value
16    else if (tag === 'n') {
17      const cut = value.lastIndexOf(':')
18      const port = Number(value.slice(cut + 1))
19      const address = value.slice(0, cut)
20      const key = `${pid}:${port}`
21      const known = seen.get(key)
22      if (known) {
23        if (!known.address.includes(address)) known.address += ` ${address}`
24      } else seen.set(key, { pid, command, user, address, port })
25    }
26  }
27  return [...seen.values()].sort((a, b) => a.port - b.port)
28}
29
30// Parses `lsof -a -d cwd -p <pids> -Fpn` into pid → working directory.
31export function parseCwds(out: string): Map<number, string> {
32  const cwds = new Map<number, string>()
33  let pid = 0
34  for (const line of out.split('\n')) {
35    if (line[0] === 'p') pid = Number(line.slice(1))
36    else if (line[0] === 'n') cwds.set(pid, line.slice(1))
37  }
38  return cwds
39}
40
41export type Container = { name: string; ports: number[]; dir?: string; service?: string }
42
43// Parses `docker ps --format '{{.Names}}\t{{.Ports}}\t{{.Label "..working_dir"}}\t{{.Label "..service"}}'`;
44// the host ports are the numbers before each `->`.
45export function parseContainers(out: string): Container[] {
46  return out
47    .split('\n')
48    .filter(line => line.trim())
49    .map(line => {
50      const [name = '', ports = '', dir = '', service = ''] = line.split('\t')
51      const host = [...ports.matchAll(/:(\d+)->/g)].map(m => Number(m[1]))
52      return { name, ports: [...new Set(host)], dir: dir || undefined, ...(service ? { service } : {}) }
53    })
54}
55
56// Parses `ps -o pid=,args= -p <pids>` into pid → full command line.
57export function parseArgs(out: string): Map<number, string> {
58  const args = new Map<number, string>()
59  for (const line of out.split('\n')) {
60    const m = line.match(/^\s*(\d+)\s+(.*)$/)
61    if (m) args.set(Number(m[1]), m[2]!.trim())
62  }
63  return args
64}
65
66const RUNTIMES = new Set(['node', 'bun', 'deno', 'npm', 'npx', 'pnpm', 'yarn', 'tsx', 'vite'])
67
68function base(path: string) {
69  return path.split('/').pop() ?? path
70}
71
72// What a JS server runs: the last package named under node_modules (vite,
73// tsx; the last one, so a pnpm store path reads as the package) and the
74// project's own script file given to it, else the runtime and that script.
75// Undefined when the process is no JS runtime.
76export function describeJs(command: string, args: string): string | undefined {
77  // `node_modules/.bin/../vite/bin/vite.js` is how a .bin shim starts Vite.
78  const tokens = args.split(/\s+/).map(t => t.replace(/[^/\s]+\/\.\.\//g, ''))
79  const runtime = base(tokens[0] ?? '')
80  if (!RUNTIMES.has(runtime) && !RUNTIMES.has(command)) return undefined
81  const pkgs = [...tokens.join(' ').matchAll(/node_modules\/(?:\.bin\/)?((?:@[^/\s]+\/)?[^/\s]+)/g)]
82    .map(m => m[1]!)
83    .filter(p => !p.startsWith('.'))
84  const pkg = pkgs[pkgs.length - 1]
85  // A script file wins over a flag's value (`--import tsx/esm src/server.ts`).
86  const own = tokens.slice(1).filter(t => !t.startsWith('-') && !t.includes('node_modules'))
87  const script = own.find(t => /\.[cm]?[jt]sx?$/.test(t)) ?? (pkg ? undefined : own[0])
88  if (pkg) return script ? `${pkg} ${base(script)}` : pkg
89  return script ? `${runtime} ${base(script)}` : runtime
90}
91
92/** A port the project's own files say something listens on. */
93export type Declared = { port: number; name: string; source: string }
94
95// The files that declare ports, matched against `git ls-files` paths.
96export const PORT_FILES =
97  /(^|\/)((docker-)?compose[^/]*\.ya?ml|package\.json|(vite|astro|next|nuxt|svelte|webpack)\.config\.[cm]?[jt]s|\.env[^/]*|justfile|Justfile|Makefile|Procfile[^/]*|launch\.json)$/
98
99function valid(port: number) {
100  return Number.isInteger(port) && port >= 1024 && port <= 65535
101}
102
103function dirName(path: string) {
104  const parts = path.split('/')
105  return parts.length > 1 ? parts[parts.length - 2]! : ''
106}
107
108// Reads the ports one project file declares. Each kind of file has its own
109// shape; anything unrecognised declares nothing.
110export function declaredPorts(path: string, text: string): Declared[] {
111  const file = base(path)
112  const found: Declared[] = []
113  const add = (port: number, name: string) => {
114    if (valid(port) && !found.some(d => d.port === port)) found.push({ port, name, source: path })
115  }
116
117  if (/compose[^/]*\.ya?ml$/.test(file)) {
118    // services:\n  <name>:\n    ports:\n      - "<host>:<container>"
119    let service = ''
120    let inPorts = false
121    let portsIndent = 0
122    for (const line of text.split('\n')) {
123      const indent = line.length - line.trimStart().length
124      const body = line.trim()
125      if (!body || body.startsWith('#')) continue
126      const key = body.match(/^([\w.-]+):\s*$/)
127      if (indent === 2 && key) service = key[1]!
128      if (inPorts && indent <= portsIndent && !body.startsWith('-')) inPorts = false
129      if (/^ports:\s*$/.test(body)) {
130        inPorts = true
131        portsIndent = indent
132        continue
133      }
134      if (inPorts && body.startsWith('-')) {
135        // "${VAR:-5433}:5432", "127.0.0.1:8080:80", 8080:80, "3000"
136        const spec = body.replace(/^-\s*/, '').replace(/["']/g, '').replace(/\$\{\w+:-(\d+)\}/g, '$1')
137        const parts = spec.split('/')[0]!.split(':')
138        add(Number(parts.length >= 2 ? parts[parts.length - 2] : parts[0]), service)
139      }
140    }
141    return found
142  }
143
144  if (file === 'package.json') {
145    let scripts: Record<string, string> = {}
146    try {
147      scripts = (JSON.parse(text) as { scripts?: Record<string, string> }).scripts ?? {}
148    } catch {
149      return found
150    }
151    for (const [script, line] of Object.entries(scripts)) {
152      for (const m of line.matchAll(/(?:--port[= ]|\bPORT=|\s-p\s)(\d+)/g)) {
153        add(Number(m[1]), `${dirName(path) || 'root'} ${script}`)
154      }
155    }
156    return found
157  }
158
159  if (/\.config\.[cm]?[jt]s$/.test(file)) {
160    for (const m of text.matchAll(/\bport\s*:\s*(\d+)/g)) add(Number(m[1]), `${dirName(path) || 'root'} ${file.split('.')[0]}`)
161    return found
162  }
163
164  if (file === 'launch.json') {
165    for (const m of text.matchAll(/"name"\s*:\s*"([^"]+)"[^}]*?"port"\s*:\s*(\d+)/g)) add(Number(m[2]), m[1]!)
166    return found
167  }
168
169  // .env files, justfile, Makefile, Procfile: FOO_PORT=4000, api_port=$((3000 + …)),
170  // ${FOO_PORT:-4000}, --port 4000
171  for (const m of text.matchAll(/\b(\w*port\w*)\s*:?=\s*["']?\$?\(*\s*(\d{4,5})\b/gi)) add(Number(m[2]), m[1]!)
172  for (const m of text.matchAll(/\$\{(\w*PORT\w*):-(\d{4,5})\}/g)) add(Number(m[2]), m[1]!)
173  for (const m of text.matchAll(/--port[= ](\d{4,5})\b/g)) add(Number(m[1]), file)
174  return found
175}
176
177export type Group<T> = { checkout: string; servers: T[] }
178
179// Groups servers under the checkout they were started from: the session's own
180// first, then the rest by path, servers outside any checkout last ('').
181export function groupByCheckout<T extends { checkout?: string; port: number }>(
182  servers: T[],
183  first: string,
184): Group<T>[] {
185  const groups = new Map<string, T[]>()
186  for (const s of servers) {
187    const key = s.checkout ?? ''
188    groups.set(key, [...(groups.get(key) ?? []), s])
189  }
190  const rank = (key: string) => (key === first ? 0 : key === '' ? 2 : 1)
191  return [...groups.entries()]
192    .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
193    .map(([checkout, list]) => ({ checkout, servers: list.sort((x, y) => x.port - y.port) }))
194}
195
types/index.d.ts 35 lines
1export type Server = {
2  pid: number
3  command: string
4  user: string
5  address: string
6  port: number
7  cwd?: string
8  /** The Docker container publishing this port; its process is Docker's own. */
9  container?: string
10  /** What a JS runtime process runs (`vite`, `node server.ts`); absent for anything else. */
11  tool?: string
12  /** The root of the git checkout (worktree) cwd sits in; absent outside any. */
13  checkout?: string
14  /** The name the project's files give this port, and the file that declares it. */
15  role?: string
16  source?: string
17  /** True when the project declares this port, or it is this repository's container or JS server. */
18  isProject?: boolean
19}
20
21export type Snapshot = {
22  servers: Server[]
23  at: number
24  project?: string
25  /** How many ports the project's files declare. */
26  declared?: number
27  error?: string
28}
29
30declare module 'claude-code' {
31  interface PluginState {
32    'running-servers': { snapshot: Snapshot; armed: string | null; showAll: boolean }
33  }
34}
35