Pane listing processes started by Claude sessions, orphans included

hooks/register.tsx 288 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Job } from '../types'
5
6const PERIOD = 3000
7const SYSTEM = /^(\/System|\/usr|\/sbin|\/bin|\/Applications|\/Library)\//
8const SHELL = /^(\/\S*\/)?(zsh|bash|sh)(\s|$)/
9const CLAUDE = /^claude(\s|$)/
10const WRAPPER = '/.claude/shell-snapshots/'
11const DIR = 30
12const CPU = 5
13const MEM = 6
14const UP = 9
15const OWN = 8
16
17const jobs = atom({ plugin: 'bgjobs', key: 'jobs' } as const, [] as Job[])
18const isOpen = atom({ plugin: 'bgjobs', key: 'isOpen' } as const, false)
19
20const envOf = (line: string, key: string) =>
21 line.match(new RegExp(`\\s${key}=(\\S*)`))?.[1]
22
23const clip = (text: string, width: number) =>
24 text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text
25
26const tail = (text: string, width: number) =>
27 text.length > width ? `…${text.slice(text.length - Math.max(1, width - 1))}` : text
28
29const left = (text: string, width: number) => clip(text, width).padEnd(width)
30
31const memory = (kb: number) =>
32 kb >= 1048576 ? `${(kb / 1048576).toFixed(1)}G` : kb >= 1024 ? `${Math.round(kb / 1024)}M` : `${kb}K`
33
34const order = (list: readonly Job[]) =>
35 [...list].sort(
36 (a, b) =>
37 Number(a.isOwnerAlive) - Number(b.isOwnerAlive) ||
38 b.ports.length - a.ports.length ||
39 a.pgid - b.pgid,
40 )
41
42const run = async ($: EngineInterface, argv: string[]) => {
43 try {
44 return (await $.process.run(argv)).stdout
45 } catch {
46 return ''
47 }
48}
49
50const scan = async ($: EngineInterface) => {
51 const uid = (await run($, ['id', '-u'])).trim()
52 const home = (await run($, ['printenv', 'HOME'])).trim()
53 const procs = new Map<
54 number,
55 { ppid: number; pgid: number; etime: string; cpu: number; rss: number; command: string }
56 >()
57 for (const line of (
58 await run($, ['ps', '-axwwo', 'uid=,pid=,ppid=,pgid=,etime=,pcpu=,rss=,command='])
59 ).split('\n')) {
60 const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+(\d+)\s+(.*)$/)
61 if (m && m[1] === uid)
62 procs.set(Number(m[2]), {
63 ppid: Number(m[3]),
64 pgid: Number(m[4]),
65 etime: m[5],
66 cpu: Number(m[6]),
67 rss: Number(m[7]),
68 command: m[8],
69 })
70 }
71
72 const candidates = [...procs]
73 .filter(([, p]) => !SYSTEM.test(p.command))
74 .map(([pid]) => pid)
75 const owners = new Map<number, number>()
76 for (let i = 0; i < candidates.length; i += 100) {
77 const out = await run($, [
78 'ps',
79 '-wwE',
80 '-p',
81 candidates.slice(i, i + 100).join(','),
82 '-o',
83 'pid=,command=',
84 ])
85 for (const line of out.split('\n')) {
86 const pid = Number(line.match(/^\s*(\d+)\s/)?.[1])
87 const session = envOf(line, 'CLAUDE_CODE_SESSION_ID')
88 const owner = Number(envOf(line, 'CLAUDE_PID'))
89 if (pid && session && owner && pid !== owner) owners.set(pid, owner)
90 }
91 }
92
93 const roots = new Set<number>()
94 for (const pid of owners.keys()) roots.add(procs.get(pid)!.pgid)
95 for (const [, p] of procs) if (p.command.includes(WRAPPER)) roots.add(p.pgid)
96
97 const groups = new Map<number, number[]>()
98 for (const [pid, p] of procs)
99 if (roots.has(p.pgid) && !CLAUDE.test(p.command))
100 groups.set(p.pgid, [...(groups.get(p.pgid) ?? []), pid])
101 for (const [pgid, pids] of groups)
102 if (pids.every(pid => SHELL.test(procs.get(pid)!.command))) groups.delete(pgid)
103
104 const lead = (pids: number[]) =>
105 pids.find(pid => !SHELL.test(procs.get(pid)!.command)) ?? pids[0]
106
107 const ports = new Map<number, number[]>()
108 let current = 0
109 for (const line of (await run($, ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-Fpn'])).split(
110 '\n',
111 )) {
112 if (line[0] === 'p') current = Number(line.slice(1))
113 else if (line[0] === 'n') {
114 const port = Number(line.slice(line.lastIndexOf(':') + 1))
115 if (port) ports.set(current, [...(ports.get(current) ?? []), port])
116 }
117 }
118
119 const cwds = new Map<number, string>()
120 const leaders = [...groups.values()].map(lead)
121 if (leaders.length > 0) {
122 current = 0
123 for (const line of (
124 await run($, ['lsof', '-a', '-d', 'cwd', '-Fpn', '-p', leaders.join(',')])
125 ).split('\n')) {
126 if (line[0] === 'p') current = Number(line.slice(1))
127 else if (line[0] === 'n') cwds.set(current, line.slice(1))
128 }
129 }
130
131 const found: Job[] = [...groups].map(([pgid, pids]) => {
132 const leader = lead(pids)
133 const wrapper = pids.find(pid => procs.get(pid)!.command.includes(WRAPPER))
134 const owner =
135 pids.map(pid => owners.get(pid)).find(Boolean) ??
136 (wrapper ? procs.get(wrapper)!.ppid : 0)
137 const cwd = cwds.get(leader) ?? ''
138
139 return {
140 pgid,
141 command: procs.get(leader)!.command,
142 cwd: home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd,
143 ports: [...new Set(pids.flatMap(pid => ports.get(pid) ?? []))].sort((a, b) => a - b),
144 etime: procs.get(leader)!.etime,
145 cpu: pids.reduce((sum, pid) => sum + procs.get(pid)!.cpu, 0),
146 rssKb: pids.reduce((sum, pid) => sum + procs.get(pid)!.rss, 0),
147 isOwnerAlive: owner > 1 && procs.has(owner),
148 }
149 })
150 await update($, jobs, () => found)
151}
152
153let timer: { cancel: () => void } | undefined
154
155const shut = async ($: EngineInterface) => {
156 timer?.cancel()
157 await update($, isOpen, () => false)
158}
159
160export const register: Register = on => {
161 on('command.run', { command: 'bgjobs' }, async $ => {
162 if (await read($, isOpen)) {
163 await shut($)
164
165 return { text: 'Background jobs closed.' }
166 }
167
168 await update($, isOpen, () => true)
169 await scan($)
170 timer?.cancel()
171 timer = $.clock.every(PERIOD, () => {
172 void (async () => {
173 if (!(await read($, isOpen))) {
174 timer?.cancel()
175 return
176 }
177 await scan($)
178 })()
179 })
180
181 return { text: 'Background jobs shown above the prompt.' }
182 })
183
184 on('session.start', async ($, e, next) => {
185 await $.command.register({
186 name: 'bgjobs',
187 description: 'Show processes started by Claude sessions above the prompt',
188 })
189
190 return next(e)
191 })
192
193 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
194 if (e.props.hasSurvey || !(await read($, isOpen))) return next(e)
195
196 const { Box, Text } = $.ui.resolve(e)
197 const list = order(await read($, jobs))
198 const cols = e.props.bodyColumns - 2
199 const cap = Math.max(1, e.props.maxRows - 4)
200 const shown = list.slice(0, cap)
201 const more = list.length - shown.length
202 const orphans = list.filter(job => !job.isOwnerAlive).length
203 const hasDir = cols >= 90
204 const ports = (job: Job) => job.ports.map(port => `:${port}`).join(',')
205 const portW = Math.min(14, Math.max(4, ...shown.map(job => ports(job).length)))
206 const dirW = hasDir ? Math.min(DIR, Math.max(3, ...shown.map(job => job.cwd.length))) : 0
207 const gaps = (hasDir ? 6 : 5) * 2
208 const fixed = portW + dirW + CPU + MEM + UP + OWN + gaps
209 const wide = Math.min(
210 40,
211 Math.max(7, ...shown.map(job => job.command.length)),
212 Math.max(10, cols - fixed),
213 )
214 const line = (key: string, child: unknown) => (
215 <Box key={key}>
216 <Text color="claude">┃ </Text>
217 <Box flexGrow={1}>{child}</Box>
218 </Box>
219 )
220
221 return (
222 <Box flexDirection="column" marginTop={1}>
223 {line(
224 'head',
225 <Text wrap="truncate-end">
226 <Text bold color="claude">
227 BACKGROUND JOBS
228 </Text>
229 <Text color="subtle">
230 {' · '}
231 {list.length} {list.length === 1 ? 'job' : 'jobs'}
232 </Text>
233 {orphans > 0 && (
234 <Text color="warning">
235 {' · '}
236 {orphans} {orphans === 1 ? 'orphan' : 'orphans'}
237 </Text>
238 )}
239 {more > 0 && <Text color="subtle">{` · ${more} more`}</Text>}
240 </Text>,
241 )}
242 {line(
243 'cols',
244 <Text bold color="subtle" wrap="truncate-end">
245 {left('COMMAND', wide)}
246 {' '}
247 {left('PORT', portW)}
248 {' '}
249 {hasDir ? `${left('DIR', dirW)} ` : ''}
250 {left('CPU', CPU)}
251 {' '}
252 {left('MEM', MEM)}
253 {' '}
254 {left('UP', UP)}
255 {' '}
256 {left('OWNER', OWN)}
257 </Text>,
258 )}
259 {line(
260 'rule',
261 <Text color="inverseText">{'─'.repeat(Math.max(1, Math.min(cols - 2, wide + portW + dirW + CPU + MEM + UP + OWN + gaps)))}</Text>,
262 )}
263 {list.length === 0 &&
264 line(
265 'empty',
266 <Text color="subtle">No background jobs found. Processes started by Claude show up here.</Text>,
267 )}
268 {shown.map(job =>
269 line(
270 String(job.pgid),
271 <Text wrap="truncate-end">
272 <Text color="text">{`${left(job.command, wide)} `}</Text>
273 <Text color="claude">{`${left(ports(job), portW)} `}</Text>
274 {hasDir && <Text color="subtle">{`${tail(job.cwd, dirW).padEnd(dirW)} `}</Text>}
275 <Text color="subtle">
276 {`${left(`${job.cpu.toFixed(1)}%`, CPU)} ${left(memory(job.rssKb), MEM)} ${left(job.etime, UP)} `}
277 </Text>
278 <Text color={job.isOwnerAlive ? 'success' : 'warning'}>
279 {left(job.isOwnerAlive ? '● alive' : '○ orphan', OWN)}
280 </Text>
281 </Text>,
282 ),
283 )}
284 </Box>
285 )
286 })
287}
288types/index.d.ts 20 lines1export type Job = {
2 pgid: number
3 command: string
4 cwd: string
5 ports: number[]
6 etime: string
7 cpu: number
8 rssKb: number
9 isOwnerAlive: boolean
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 bgjobs: {
15 jobs: Job[]
16 isOpen: boolean
17 }
18 }
19}
20