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

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.
| When | What you see |
|---|---|
| Nothing Claude started is running | nothing |
| Something is running | one 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 runs | a dialog listing the services: Stop all or Keep running |
| A new session starts while services from an earlier one still run | a toast pointing to /services |
No setup needed.
| Started with | Stopped with | Running check |
|---|---|---|
docker compose up -d (also docker-compose) | docker compose down in the same folder, with the same -f, -p and --profile flags | docker compose ps |
docker compose start | docker compose stop, which keeps the containers | docker compose ps |
docker run -d | docker 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 / restart | brew services stop | brew services info --json |
launchctl load | launchctl unload | none — 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.
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.Remove the mod from CLAUDE_CODE_PLUGIN_DIRS. Services it tracked keep running; stop them first with /services if you want them gone.
hooks/register.tsx 270 lines1import { 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}
270hooks/detect.ts 252 lines1import 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}
252types/index.d.ts 26 lines1export 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