SLOPSHOPPER

service-radar

Keeps track of detached services Claude starts and stops them on request

newpaneguardcommandtoaststatus
★ 2v0.1.0MITupdated 2026-10-03MichaelP17/claude-mods/service-radar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · service-radar
│ ┃ Services ✕ › fix the failing auth test and add an audit log call │ ┃ No services started by Claude are running. │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Refresh ][ Close ] ⎿ 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 │ │ › /services │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Services
No services started by Claude are running. [ Refresh ][ Close ]
README

service-radar

Keeps track of services Claude starts in the background and that keep running after the command returned — a Docker Compose stack, a container, the Colima VM, a Homebrew service — and lets you stop them.

Claude Code already lists its own background shells and subagents. What it does not show is a service that detaches: docker compose up -d returns at once, and the stack keeps running unnoticed. This mod fills that gap and stays out of sight otherwise.

WhenWhat you see
Nothing Claude started is runningnothing
Something is runningone status line entry, services 2
You want details/services opens a pane: name, folder, start time, a Stop button per service, Stop all, Refresh
/clear while something runsa dialog listing the services: Stop all or Keep running
A new session starts while services from an earlier one still runa toast pointing to /services

No setup needed.

What is tracked

Started withStopped withRunning check
docker compose up -d (also docker-compose)docker compose down in the same folder, with the same -f, -p and --profile flagsdocker compose ps
docker compose startdocker compose stop, which keeps the containersdocker compose ps
docker run -ddocker stop <name or id>docker ps
docker start <name>docker stop <name>docker ps
colima start [profile]colima stop [profile]colima status
brew services start / run / restartbrew services stopbrew services info --json
launchctl loadlaunchctl unloadnone — shown with ? and a Forget button
launchctl bootstrap <domain> <plist>launchctl bootout <domain> <plist>none — shown with ? and a Forget button

Services are matched in chained commands too, including a preceding cd: in cd app && docker compose up -d the stack is tracked in app. A service that was stopped some other way — by you in a terminal, or by Claude running the stop command — drops off the list the next time it is checked.

Details and limits

  • The list is kept across sessions, because services outlive them. Exiting Claude Code cannot show a dialog, so the next session reminds you instead.
  • Only commands Claude runs through its Bash tool are seen. Services you start yourself are not listed.
  • A tracked start is recognised behind sudo, nohup, env and similar prefixes and when sent to the background with &. Other programs sent to the background, such as npm run dev &, are not tracked; Claude Code's own background shells list and stop those.
  • Stopping runs in the background after a press, so a slow stop (Colima takes about ten seconds) is never cut short.
  • Checks run with a 20-second timeout and never delay the start of a session.

Uninstall

Remove the mod from CLAUDE_CODE_PLUGIN_DIRS. Services it tracked keep running; stop them first with /services if you want them gone.

Source 3 files
hooks/register.tsx 270 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Service } from '../types'
5import { detectServices, isRunning, mayStopServices } from './detect'
6
7const PANE = 'service-radar'
8const STORE_KEY = 'services'
9const STOP_ALL = 'Stop all'
10const KEEP = 'Keep running'
11const CHECK_TIMEOUT_MS = 20_000
12const STOP_TIMEOUT_MS = 120_000
13
14const services = atom({ plugin: 'service-radar', key: 'services' } as const, [])
15const isBusy = atom({ plugin: 'service-radar', key: 'isBusy' } as const, false)
16const message = atom({ plugin: 'service-radar', key: 'message' } as const, '')
17
18// Services outlive the session that started them, so the list lives in the
19// plugin's store; the state atom only mirrors it for drawing.
20async function loadServices($: EngineInterface): Promise<Service[]> {
21  const stored = await $.store.get(STORE_KEY)
22
23  return Array.isArray(stored) ? (stored as Service[]) : []
24}
25
26async function saveServices($: EngineInterface, list: Service[]): Promise<void> {
27  await $.store.set(STORE_KEY, list)
28  await update($, services, () => list)
29  $.ui.status(list.length === 0 ? undefined : `services ${list.length}`)
30}
31
32// The desktop app starts sessions with launchd's bare PATH, which lacks
33// Homebrew: docker, colima and brew would not be found there.
34const HOMEBREW_PATH = ['/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin']
35
36async function searchPath($: EngineInterface): Promise<string[]> {
37  const current = ((await $.env.get('PATH')) ?? '/usr/bin:/bin:/usr/sbin:/sbin').split(':').filter(i => i !== '')
38
39  return [...current, ...HOMEBREW_PATH.filter(i => !current.includes(i))]
40}
41
42// The child's PATH alone may not steer the lookup of argv[0], so the
43// executable is resolved here; on any doubt the bare name is passed on.
44async function resolveExecutable($: EngineInterface, command: string, directories: readonly string[]): Promise<string> {
45  if (command.includes('/')) {
46    return command
47  }
48  for (const directory of directories) {
49    try {
50      if (await $.fs.exists(`${directory}/${command}`)) {
51        return `${directory}/${command}`
52      }
53    } catch {
54      return command
55    }
56  }
57
58  return command
59}
60
61async function runCommand($: EngineInterface, argv: readonly string[], cwd: string, timeoutMs: number) {
62  const directories = await searchPath($)
63  const [command = '', ...args] = argv
64  const executable = await resolveExecutable($, command, directories)
65
66  return $.process.run([executable, ...args], {
67    cwd,
68    timeoutMs,
69    env: { HOMEBREW_NO_AUTO_UPDATE: '1', PATH: directories.join(':') },
70  })
71}
72
73async function stillRunning($: EngineInterface, service: Service): Promise<boolean> {
74  if (service.check === null) {
75    return true
76  }
77  try {
78    const result = await runCommand($, service.check.argv, service.cwd, CHECK_TIMEOUT_MS)
79
80    return isRunning(service.check.rule, result.exitCode, result.stdout)
81  } catch {
82    return false
83  }
84}
85
86async function refresh($: EngineInterface): Promise<Service[]> {
87  const list = await loadServices($)
88  const alive: Service[] = []
89  for (const service of list) {
90    if (await stillRunning($, service)) {
91      alive.push(service)
92    }
93  }
94  await saveServices($, alive)
95
96  return alive
97}
98
99async function stopService($: EngineInterface, service: Service): Promise<string> {
100  try {
101    const result = await runCommand($, service.stop, service.cwd, STOP_TIMEOUT_MS)
102    if (result.exitCode !== 0 && (await stillRunning($, service))) {
103      return `${service.label}: stop failed (${result.stderr.trim().split('\n').pop() ?? `exit ${result.exitCode}`})`
104    }
105  } catch (error) {
106    return `${service.label}: stop failed (${error instanceof Error ? error.message : 'unknown error'})`
107  }
108  await saveServices($, (await loadServices($)).filter(i => i.id !== service.id))
109
110  return `${service.label}: stopped`
111}
112
113async function stopAll($: EngineInterface): Promise<string> {
114  const lines: string[] = []
115  for (const service of await loadServices($)) {
116    lines.push(await stopService($, service))
117  }
118
119  return lines.join('\n')
120}
121
122async function busy($: EngineInterface, work: () => Promise<string>): Promise<void> {
123  if (await read($, isBusy)) {
124    return
125  }
126  await update($, isBusy, () => true)
127  try {
128    const text = await work()
129    await update($, message, () => text)
130  } finally {
131    await update($, isBusy, () => false)
132  }
133}
134
135// A press handler runs under Claude Code's 10-second hook budget, and stopping
136// a VM takes longer; the work is handed to a timer, which has no such limit.
137function inBackground($: EngineInterface, work: () => Promise<string>): void {
138  $.clock.after(0, () => {
139    void busy($, work)
140  })
141}
142
143async function forget($: EngineInterface, id: string): Promise<void> {
144  await saveServices($, (await loadServices($)).filter(i => i.id !== id))
145}
146
147function shortPath(path: string, home: string | undefined): string {
148  return home !== undefined && path.startsWith(home) ? `~${path.slice(home.length)}` : path
149}
150
151function clockTime(timestamp: number): string {
152  const date = new Date(timestamp)
153
154  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
155}
156
157export const register: Register = on => {
158  on('session.start', async ($, e, next) => {
159    await $.command.register({ name: 'services', description: 'Show the services Claude started and stop them' })
160    // Checking services can take seconds (a slow Docker daemon); startup must not wait for it.
161    void refresh($).then(alive => {
162      if (alive.length > 0) {
163        $.ui.toast(`${alive.length} service${alive.length === 1 ? '' : 's'} Claude started earlier still running. /services shows them.`)
164      }
165    })
166
167    return next(e)
168  })
169
170  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
171    const cwd = await $.session.cwd()
172    const result = await next(e)
173    if (result.deny !== undefined || result.isError === true) {
174      return result
175    }
176
177    const output = typeof result.result.stdout === 'string' ? result.result.stdout : ''
178    const home = (await $.env.get('HOME')) ?? ''
179    const detected = detectServices(e.command, cwd, home, output)
180    if (detected.length > 0) {
181      const now = await $.clock.now()
182      const list = await loadServices($)
183      const added = detected
184        .filter(found => !list.some(known => known.label === found.label && known.cwd === found.cwd))
185        .map((found, index) => ({ ...found, id: `${now}-${index}`, startedAt: now }))
186      await saveServices($, [...list, ...added])
187    } else if (mayStopServices(e.command) && (await loadServices($)).length > 0) {
188      void refresh($)
189    }
190
191    return result
192  })
193
194  on('command.run', { command: 'services' }, async $ => {
195    await refresh($)
196    await update($, message, () => '')
197    await $.ui.open({ id: PANE, title: 'Services', focus: true })
198
199    return {}
200  })
201
202  // A /clear is the last moment to ask: on exit Claude Code allows no dialog,
203  // and the list is kept in the store for the next session instead.
204  on('command.run', { command: 'clear' }, async ($, e, next) => {
205    const alive = await refresh($)
206    if (alive.length === 0) {
207      return next(e)
208    }
209    const names = alive.slice(0, 5).map(i => `- ${i.label}`).join('\n')
210    const more = alive.length > 5 ? `\n- and ${alive.length - 5} more` : ''
211    let answer = KEEP
212    try {
213      answer = await $.ui.ask(`Services Claude started are still running:\n${names}${more}\n\nStop them before clearing?`, {
214        options: [STOP_ALL, KEEP],
215        header: 'Services',
216      })
217    } catch {
218      answer = KEEP
219    }
220    if (answer === STOP_ALL) {
221      $.ui.toast(await stopAll($))
222    }
223
224    return next(e)
225  })
226
227  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
228    const { Box, Text, Button } = $.ui.resolve(e)
229    const list = await read($, services)
230    const running = await read($, isBusy)
231    const text = await read($, message)
232    const home = await $.env.get('HOME')
233
234    return (
235      <Box flexDirection="column">
236        {list.length === 0 && <Text dimColor>No services started by Claude are running.</Text>}
237        {list.map(service => (
238          <Box key={`row-${service.id}`}>
239            <Text>
240              {service.check === null ? '?' : '●'} {service.label}
241            </Text>
242            <Text dimColor>
243              {'  '}
244              {shortPath(service.cwd, home)} · since {clockTime(service.startedAt)}
245              {'  '}
246            </Text>
247            <Button key={`stop-${service.id}`} label="Stop" onPress={() => inBackground($, () => stopService($, service))} />
248            {service.check === null && (
249              <Button key={`forget-${service.id}`} dimColor label="Forget" onPress={() => forget($, service.id)} />
250            )}
251          </Box>
252        ))}
253
254        {running && <Text dimColor>Working…</Text>}
255        {!running && text.length > 0 && (
256          <Box marginTop={1}>
257            <Text>{text}</Text>
258          </Box>
259        )}
260
261        <Box marginTop={1}>
262          {list.length > 1 && <Button key="stop-all" label="Stop all" onPress={() => inBackground($, () => stopAll($))} />}
263          <Button key="refresh" dimColor label="Refresh" onPress={() => inBackground($, async () => `${(await refresh($)).length} running`)} />
264          <Button key="close" role="dismiss" dimColor label="Close" onPress={() => $.ui.close({ id: PANE })} />
265        </Box>
266      </Box>
267    )
268  })
269}
270
hooks/detect.ts 252 lines
1import type { ServiceCheck } from '../types'
2
3export type Detected = { label: string; cwd: string; stop: string[]; check: ServiceCheck | null }
4
5const COMPOSE_GLOBAL_FLAGS = new Set(['-f', '--file', '-p', '--project-name', '--profile', '--env-file', '--project-directory'])
6
7const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'fish', 'ssh', 'sudo', 'xargs', 'eval', 'source', '.'])
8
9// A heredoc body is data for the program it is fed to; only a shell turns it
10// back into commands. Bodies fed to anything else (python3, cat, tee) are left
11// out of the check, so writing documentation that mentions commands is not
12// mistaken for running them.
13export function stripHeredocs(command: string): string {
14  const lines = command.split('\n')
15  const kept: string[] = []
16  let index = 0
17  while (index < lines.length) {
18    const line = lines[index] ?? ''
19    kept.push(line)
20    index += 1
21    const marker = /<<(-?)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/.exec(line)
22    if (marker === null) {
23      continue
24    }
25    const segment = line.slice(0, marker.index).split(/&&|\|\||[;|]/).pop() ?? ''
26    const program = segment.trim().split(/\s+/).find(i => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(i)) ?? ''
27    const pipesIntoShell = /\|\s*(sudo\s+)?(ba|z|da)?sh\b/.test(line.slice(marker.index))
28    if (SHELLS.has(program.split('/').pop() ?? '') || pipesIntoShell) {
29      continue
30    }
31    const delimiter = marker[3]
32    const stripsTabs = marker[1] === '-'
33    while (index < lines.length) {
34      const body = lines[index] ?? ''
35      index += 1
36      if ((stripsTabs ? body.replace(/^\t+/, '') : body) === delimiter) {
37        kept.push(body)
38        break
39      }
40    }
41  }
42
43  return kept.join('\n')
44}
45
46// Prefixes that run the next word as the actual program.
47const WRAPPERS = new Set(['sudo', 'nohup', 'command', 'exec', 'time', 'env', 'setsid', 'caffeinate'])
48
49function splitSegments(command: string): string[] {
50  return command
51    .split(/&&|\|\||[;|\n]/)
52    .map(i => i.trim())
53    .filter(i => i.length > 0)
54}
55
56function tokenize(segment: string): string[] {
57  const raw = (segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(i => i.replace(/^(["'])(.*)\1$/, '$2'))
58  // Redirections (`2>&1`, `> log`, `2>/dev/null`) and a trailing `&` belong to
59  // the shell, not to the program; read as arguments they would turn into a
60  // bogus Colima profile or container name.
61  const tokens: string[] = []
62  for (let index = 0; index < raw.length; index += 1) {
63    const token = raw[index] ?? ''
64    if (token === '&') {
65      continue
66    }
67    if (/^(\d*|&)(>>?|<)$/.test(token)) {
68      index += 1
69      continue
70    }
71    if (/^(\d*|&)(>>?|<)\S/.test(token)) {
72      continue
73    }
74    tokens.push(token)
75  }
76  let start = 0
77  while (start < tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[start] ?? '') || WRAPPERS.has(tokens[start] ?? ''))) {
78    start += 1
79  }
80
81  return tokens.slice(start)
82}
83
84export function resolvePath(base: string, target: string, home: string): string {
85  if (target === '~' || target.startsWith('~/')) {
86    return `${home}${target.slice(1)}`
87  }
88  if (target.startsWith('/')) {
89    return target
90  }
91  const parts = base.split('/')
92  for (const part of target.split('/')) {
93    if (part === '..') {
94      parts.pop()
95    } else if (part !== '.' && part !== '') {
96      parts.push(part)
97    }
98  }
99
100  return parts.join('/') || '/'
101}
102
103function folderName(path: string): string {
104  return path.split('/').filter(i => i.length > 0).pop() ?? path
105}
106
107function detectCompose(args: readonly string[], cwd: string): Detected | null {
108  const globalFlags: string[] = []
109  let index = 0
110  while (index < args.length && args[index]?.startsWith('-')) {
111    const flag = args[index] ?? ''
112    globalFlags.push(flag)
113    if (COMPOSE_GLOBAL_FLAGS.has(flag) && args[index + 1] !== undefined) {
114      globalFlags.push(args[index + 1] ?? '')
115      index += 1
116    }
117    index += 1
118  }
119  const verb = args[index]
120  const verbArgs = args.slice(index + 1)
121  const isDetachedUp = verb === 'up' && verbArgs.some(i => i === '-d' || i === '--detach' || /^-[a-z]*d[a-z]*$/.test(i))
122  if (!isDetachedUp && verb !== 'start') {
123    return null
124  }
125  const projectIndex = globalFlags.findIndex(i => i === '-p' || i === '--project-name')
126  const project = projectIndex === -1 ? folderName(cwd) : globalFlags[projectIndex + 1] ?? folderName(cwd)
127
128  return {
129    label: `docker compose: ${project}`,
130    cwd,
131    // `up` created the containers, so `down` removes them again; `start` only
132    // resumed existing ones, which `stop` leaves in place.
133    stop: ['docker', 'compose', ...globalFlags, verb === 'start' ? 'stop' : 'down'],
134    check: { argv: ['docker', 'compose', ...globalFlags, 'ps', '--status', 'running', '-q'], rule: 'output' },
135  }
136}
137
138function detectDockerRun(args: readonly string[], cwd: string, output: string): Detected | null {
139  if (!args.some(i => i === '-d' || i === '--detach' || /^-[a-z]*d[a-z]*$/.test(i))) {
140    return null
141  }
142  const nameIndex = args.findIndex(i => i === '--name')
143  const inlineName = args.find(i => i.startsWith('--name='))?.slice('--name='.length)
144  const name = inlineName ?? (nameIndex === -1 ? undefined : args[nameIndex + 1])
145  const containerId = /^([0-9a-f]{12,64})\s*$/m.exec(output)?.[1]
146  const reference = name ?? containerId?.slice(0, 12)
147  if (reference === undefined) {
148    return null
149  }
150
151  return {
152    label: `docker container: ${reference}`,
153    cwd,
154    stop: ['docker', 'stop', reference],
155    check: { argv: ['docker', 'ps', '-q', '--filter', name === undefined ? `id=${reference}` : `name=^${name}$`], rule: 'output' },
156  }
157}
158
159export function detectServices(command: string, startCwd: string, home: string, output: string): Detected[] {
160  const found: Detected[] = []
161  let cwd = startCwd
162
163  for (const segment of splitSegments(stripHeredocs(command))) {
164    const [tool, ...args] = tokenize(segment)
165    if (tool === undefined) {
166      continue
167    }
168    if (tool === 'cd') {
169      cwd = resolvePath(cwd, args[0] ?? '~', home)
170      continue
171    }
172
173    if (tool === 'docker' && args[0] === 'start') {
174      for (const name of args.slice(1).filter(i => !i.startsWith('-'))) {
175        found.push({
176          label: `docker container: ${name}`,
177          cwd,
178          stop: ['docker', 'stop', name],
179          check: { argv: ['docker', 'ps', '-q', '--filter', `name=^${name}$`], rule: 'output' },
180        })
181      }
182      continue
183    }
184
185    let detected: Detected | null = null
186    if (tool === 'docker' && args[0] === 'compose') {
187      detected = detectCompose(args.slice(1), cwd)
188    } else if (tool === 'docker-compose') {
189      detected = detectCompose(args, cwd)
190    } else if (tool === 'docker' && args[0] === 'run') {
191      detected = detectDockerRun(args.slice(1), cwd, output)
192    } else if (tool === 'colima' && args[0] === 'start') {
193      const profileIndex = args.findIndex(i => i === '-p' || i === '--profile')
194      const profile = profileIndex === -1 ? args.slice(1).find(i => !i.startsWith('-')) : args[profileIndex + 1]
195      const profileArgs = profile === undefined ? [] : [profile]
196      detected = {
197        label: profile === undefined ? 'colima' : `colima: ${profile}`,
198        cwd,
199        stop: ['colima', 'stop', ...profileArgs],
200        check: { argv: ['colima', 'status', ...profileArgs], rule: 'exit-zero' },
201      }
202    } else if (tool === 'brew' && args[0] === 'services' && ['start', 'run', 'restart'].includes(args[1] ?? '')) {
203      const formula = args.slice(2).find(i => !i.startsWith('-'))
204      if (formula !== undefined) {
205        detected = {
206          label: `brew service: ${formula}`,
207          cwd,
208          stop: ['brew', 'services', 'stop', formula],
209          check: { argv: ['brew', 'services', 'info', formula, '--json'], rule: 'brew' },
210        }
211      }
212    } else if (tool === 'launchctl' && args[0] === 'bootstrap' && args[1] !== undefined && args[2] !== undefined) {
213      const absolute = resolvePath(cwd, args[2], home)
214      detected = { label: `launch agent: ${folderName(absolute)}`, cwd, stop: ['launchctl', 'bootout', args[1], absolute], check: null }
215    } else if (tool === 'launchctl' && args[0] === 'load') {
216      const path = args.slice(1).find(i => !i.startsWith('-'))
217      if (path !== undefined) {
218        const absolute = resolvePath(cwd, path, home)
219        detected = { label: `launch agent: ${folderName(absolute)}`, cwd, stop: ['launchctl', 'unload', absolute], check: null }
220      }
221    }
222
223    if (detected !== null) {
224      found.push(detected)
225    }
226  }
227
228  return found
229}
230
231export function isRunning(rule: ServiceCheck['rule'], exitCode: number, stdout: string): boolean {
232  switch (rule) {
233    case 'exit-zero':
234      return exitCode === 0
235    case 'output':
236      return exitCode === 0 && stdout.trim().length > 0
237    case 'brew':
238      try {
239        const parsed = JSON.parse(stdout) as Array<{ running?: boolean }>
240
241        return parsed.some(i => i.running === true)
242      } catch {
243        return false
244      }
245  }
246}
247
248// Commands that may end a tracked service; after one of them every service is checked again.
249export function mayStopServices(command: string): boolean {
250  return /\b(down|stop|kill|unload|bootout|rm|prune)\b/.test(command)
251}
252
types/index.d.ts 26 lines
1export type ServiceCheck = {
2  argv: string[]
3  // exit-zero: running when the check exits 0; output: running when it prints anything;
4  // brew: running when `brew services info --json` reports it so.
5  rule: 'exit-zero' | 'output' | 'brew'
6}
7
8export type Service = {
9  id: string
10  label: string
11  cwd: string
12  startedAt: number
13  stop: string[]
14  check: ServiceCheck | null
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'service-radar': {
20      services: Service[]
21      isBusy: boolean
22      message: string
23    }
24  }
25}
26