SLOPSHOPPER

bgjobs

Pane listing processes started by Claude sessions, orphans included

newbandcommandprocesstimer
v0.1.0no licenseupdated 2026-10-09m-alekseev/.dotfiles/claude/mods/bgjobs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bgjobs
› fix the failing auth test and add an audit log call ⏺ 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 › /bgjobs ⎿ bgjobs: Background jobs shown above the prompt. ┃ BACKGROUND JOBS · 0 jobs ┃ COMMAND PORT DIR CPU MEM UP OWNER ┃ ────────────────────────────────────────────────────── ┃ No background jobs found. Processes started by Claude show up here. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
┃ BACKGROUND JOBS · 0 jobs ┃ COMMAND PORT DIR CPU MEM UP OWNER ┃ ────────────────────────────────────────────────────── ┃ No background jobs found. Processes started by Claude show up here.
Source 2 files
hooks/register.tsx 288 lines
1import { 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}
288
types/index.d.ts 20 lines
1export 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