/jobs pane: running subagents and background shells with elapsed time and Stop; a toast when background work finishes

Function-hook mods for Claude Code. Each folder is one plugin.
| Mod | What it does | ||
|---|---|---|---|
context-watch | One-line gauge above the prompt for context fill, cost and hot rate limits; toasts at 60/80/90%; compaction keeps your instructions and edited files; /ctx opens a context breakdown pane | ||
live-spinner | Spinner shows what is running: the Bash description or a trimmed command, the ssh host, file being read or edited, search, subagent, MCP tool; a tool's own elapsed time after 15s; "Waiting for signing approval" when a signed git commit or tag sits quiet | ||
pr-watch | Tracks PRs from gh pr / gt submit / git push output, shows checks above the prompt, toasts on green/red/merged, blocks foreground CI polling loops; /prs | ||
house-style | No em dashes: system prompt rule, reminder to the model after a write that adds one, toast when a reply uses one | ||
handoff | /carryover [focus] writes a state note from the conversation and clears; the next prompt carries it. /carryover save, show, drop | ||
agent-jobs | /jobs opens a pane of running subagents and background shells with elapsed time and a Stop button; toasts when background work finishes | ||
zsh-safe | Quotes glob flags zsh would expand (--include=*.ts, find -name *.ts) and keeps a grep that finds nothing from failing a cd ... && grep chain | ||
pm-guard | Blocks npm/npx/yarn where the lockfile says bun or pnpm, and steers to bun where there is no lockfile | ||
remote-guard | Asks before a destructive command (restart, rm, kill, reboot, pct/qm stop, zfs destroy) runs over ssh | ||
xcode-status | Only in a folder with an .xcodeproj/.xcworkspace: one line with the last build or test result (flags runs that executed 0 tests) and the booted simulator; blocks a second xcodebuild while one is running; re-checks on /cd and keeps each project's last result | ||
agent-models | Pins the model each subagent type runs on (Explore, general-purpose, Plan, any other, plus custom type=model pairs), set in /config or with /agent-models <type> <model>; a model the caller names wins unless "Override explicit models" is on | ||
worktrees | /worktrees pane: each git worktree with branch, uncommitted changes, ahead/behind and PR state; Remove (after asking) on clean worktrees whose PR is merged or closed, plus "Remove N merged" to clear every merged one at once | ||
pr-rules | Blocks gh pr create / gt submit when the repo keeps an [Unreleased] changelog and the branch adds no entry (skip with --label no-changelog); where the repo's CLAUDE.md says to label every PR (or requireLabels is on), blocks a PR with no --label and lists the repo's labels. Repos with neither are untouched; `/pr-rules on | off | auto` overrides per repo (kept across sessions) |
claude plugin marketplace add narrowstacks/claude-code-mods
claude plugin install context-watch@claude-code-mods
Or load from a checkout without installing:
claude --plugin-dir ./context-watch --plugin-dir ./live-spinner
claude plugin validate ./context-watch
claude plugin test ./context-watch
tsc -p ./context-watch works once Claude Code has loaded the mod and written .claude-plugin/types/.
hooks/register.tsx 331 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register } from 'claude-code'
3
4import type { Job, JobStatus } from '../types'
5
6const jobs = atom({ plugin: 'agent-jobs', key: 'jobs' } as const, [] as Job[])
7const now = atom({ plugin: 'agent-jobs', key: 'now' } as const, 0)
8const expanded = atom({ plugin: 'agent-jobs', key: 'expanded' } as const, null as string | null)
9
10const PANE = 'agent-jobs'
11const TICK_MS = 2000
12const KEEP_FINISHED = 5
13const KEEP_RECENT = 6
14
15export const formatElapsed = (ms: number) => {
16 const s = Math.max(0, Math.round(ms / 1000))
17 if (s < 60) {
18 return `${s}s`
19 }
20 const m = Math.floor(s / 60)
21 if (m < 60) {
22 return `${m}m ${s % 60}s`
23 }
24
25 return `${Math.floor(m / 60)}h ${m % 60}m`
26}
27
28const field = (input: object, key: string) => {
29 const value = (input as Record<string, unknown>)[key]
30
31 return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
32}
33
34const base = (path: string) => path.split('/').filter(Boolean).at(-1) ?? path
35
36const firstLine = (text: string) => (text.split('\n')[0] ?? '').trim()
37
38// One short line for what a tool call is doing.
39export const activityOf = (tool: string, input: object): string => {
40 const path = field(input, 'file_path') ?? field(input, 'notebook_path') ?? ''
41 switch (tool) {
42 case 'Bash':
43 return field(input, 'description') ?? `$ ${firstLine(field(input, 'command') ?? '')}`
44 case 'Read':
45 return `Reading ${base(path)}`
46 case 'Edit':
47 case 'Write':
48 case 'NotebookEdit':
49 return `Editing ${base(path)}`
50 case 'Grep':
51 return `Searching "${firstLine(field(input, 'pattern') ?? '')}"`
52 case 'Glob':
53 return `Finding ${field(input, 'pattern') ?? ''}`
54 case 'WebFetch':
55 return `Fetching ${(field(input, 'url') ?? '').replace(/^https?:\/\/([^/]+).*$/, '$1')}`
56 case 'WebSearch':
57 return `Searching web: ${field(input, 'query') ?? ''}`
58 case 'Agent':
59 case 'Task':
60 return `Agent: ${field(input, 'description') ?? 'working'}`
61 }
62 const mcp = tool.match(/^mcp__(.+?)__(.+)$/)
63
64 return mcp !== null ? `${(mcp[1] ?? '').replace(/^claude_ai_|^plugin_/, '')}: ${mcp[2] ?? ''}` : tool
65}
66
67const statusOf = (status: AgentInfo['status']): JobStatus =>
68 status === 'completed' || status === 'failed' || status === 'killed' ? status : 'running'
69
70// Keeps every running job and the newest few finished ones.
71export const trim = (list: readonly Job[]) => {
72 const finished = list
73 .filter(job => job.status !== 'running')
74 .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
75 .slice(0, KEEP_FINISHED)
76
77 return list.filter(job => job.status === 'running' || finished.includes(job))
78}
79
80// Reads `<task-id>` and `<status>` out of a background task's notification text.
81export const parseNotification = (text: string) => {
82 const id = text.match(/<task-id>([^<]+)<\/task-id>/)?.[1]?.trim()
83 const status = text.match(/<status>([^<]+)<\/status>/)?.[1]?.trim()
84
85 return id === undefined ? undefined : { id, status: status ?? 'completed' }
86}
87
88const finish = async ($: EngineInterface, id: string, status: JobStatus) => {
89 const at = await $.clock.now()
90 let done: Job | undefined
91 await update($, jobs, list =>
92 trim(
93 list.map(job => {
94 if (job.id !== id || job.status !== 'running') {
95 return job
96 }
97 done = { ...job, status, endedAt: at }
98
99 return done
100 }),
101 ),
102 )
103 if (done !== undefined && done.isBackground) {
104 const mark = status === 'completed' ? 'done' : status
105 $.ui.toast(`${done.kind === 'agent' ? 'Agent' : 'Job'} ${mark}: ${done.label}`)
106 }
107}
108
109// Background work reports its end as a notification: as a turn of its own, or
110// delivered into a running one.
111const onNotice = async ($: EngineInterface, text: string) => {
112 const notice = parseNotification(text)
113 if (notice === undefined) {
114 return
115 }
116 const status: JobStatus =
117 notice.status === 'failed' ? 'failed' : notice.status === 'killed' ? 'killed' : 'completed'
118 await finish($, notice.id, status)
119}
120
121const sync = async ($: EngineInterface, background: ReadonlySet<string>, early: Map<string, string[]>) => {
122 const [agents, at, list] = await Promise.all([$.agent.list(), $.clock.now(), read($, jobs)])
123 const known = new Map(list.map(job => [job.id, job]))
124
125 const added: Job[] = []
126 for (const agent of agents) {
127 const status = statusOf(agent.status)
128 const job = known.get(agent.id)
129 if (job === undefined && status === 'running') {
130 added.push({
131 id: agent.id,
132 kind: 'agent',
133 label: agent.description || agent.type,
134 detail: agent.type,
135 startedAt: at,
136 status,
137 isBackground: background.has(agent.id),
138 stopId: agent.name ?? agent.teammateId ?? agent.id,
139 recent: early.get(agent.id),
140 })
141 early.delete(agent.id)
142 } else if (job !== undefined && job.status === 'running' && status !== 'running') {
143 await finish($, agent.id, status)
144 }
145 }
146 if (added.length > 0) {
147 await update($, jobs, all => trim([...all, ...added]))
148 }
149
150 const isRunning = (await read($, jobs)).some(job => job.status === 'running')
151 if (isRunning || added.length > 0) {
152 await update($, now, () => at)
153 }
154}
155
156export const register: Register = on => {
157 const background = new Set<string>()
158 // Tool calls an agent made before the 2s sync listed it.
159 const early = new Map<string, string[]>()
160
161 on('session.start', async ($, e, next) => {
162 await $.command.register({ name: 'jobs', description: 'Running subagents and background shells' })
163 $.clock.every(TICK_MS, () => {
164 sync($, background, early).catch(() => undefined)
165 })
166
167 return next(e)
168 })
169
170 on('command.run', { command: 'jobs' }, async $ => {
171 await sync($, background, early).catch(() => undefined)
172 await $.ui.open({ id: PANE, title: 'Jobs' })
173
174 return { text: 'Jobs pane opened.' }
175 })
176
177 on('tool.call', async ($, e, next) => {
178 if (e.agentId !== undefined) {
179 const id = e.agentId
180 const line = activityOf(e.tool, e)
181 const push = (list: readonly string[] = []) => [...list, line].slice(-KEEP_RECENT)
182 const list = await read($, jobs)
183 if (list.some(job => job.id === id)) {
184 await update($, jobs, all => all.map(job => (job.id === id ? { ...job, recent: push(job.recent) } : job)))
185 } else {
186 early.set(id, push(early.get(id)))
187 }
188 }
189
190 const ran = await next(e)
191 if (ran.deny !== undefined || ran.isError === true) {
192 return ran
193 }
194
195 const result = ran.result as Record<string, unknown> | undefined
196 if (e.tool === 'Agent' && result?.status === 'async_launched' && typeof result.agentId === 'string') {
197 const id = result.agentId
198 background.add(id)
199 await update($, jobs, list => list.map(job => (job.id === id ? { ...job, isBackground: true } : job)))
200 }
201 if (e.tool === 'Bash' && typeof result?.backgroundTaskId === 'string') {
202 const id = result.backgroundTaskId
203 const job: Job = {
204 id,
205 kind: 'shell',
206 label: e.description ?? e.command.split('\n')[0] ?? 'shell',
207 detail: e.description !== undefined ? e.command.split('\n')[0] : undefined,
208 startedAt: await $.clock.now(),
209 status: 'running',
210 isBackground: true,
211 stopId: id,
212 }
213 await update($, jobs, list => trim([...list.filter(one => one.id !== id), job]))
214 }
215 if (e.tool === 'TaskStop') {
216 const id = e.task_id ?? e.shell_id
217 const match = (await read($, jobs)).find(job => job.stopId === id || job.id === id)
218 if (match !== undefined) {
219 await finish($, match.id, 'killed')
220 }
221 }
222
223 return ran
224 })
225
226 on('turn.complete', async ($, e, next) => {
227 const id = e.agentId
228 if (id !== undefined && e.answer.trim() !== '') {
229 const outcome = firstLine(e.answer.trim())
230 await update($, jobs, all => all.map(job => (job.id === id ? { ...job, outcome } : job)))
231 }
232
233 return next(e)
234 })
235
236 on('prompt.submit', async ($, e, next) => {
237 if (e.origin.kind === 'task-notification') {
238 await onNotice($, e.text)
239 }
240
241 return next(e)
242 })
243
244 on('session.receive', async ($, e, next) => {
245 if (e.origin.kind === 'task-notification') {
246 await onNotice($, e.text)
247 }
248
249 return next(e)
250 })
251
252 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
253 const { Box, Text, Button } = $.ui.resolve(e)
254 const list = await read($, jobs)
255 const open = await read($, expanded)
256 const at = Math.max(await read($, now), ...list.map(job => job.startedAt))
257 const running = list.filter(job => job.status === 'running')
258 const finished = list.filter(job => job.status !== 'running')
259 const width = Math.max(20, (e.viewport?.columns ?? 80) - 34)
260 const clip = (text: string, room = width) => (text.length > room ? `${text.slice(0, room - 1)}…` : text)
261
262 const stop = async (job: Job) => {
263 const ran = await $.tool.call({ tool: 'TaskStop', task_id: job.stopId })
264 if (ran.deny === undefined && ran.isError !== true) {
265 await finish($, job.id, 'killed')
266 } else {
267 $.ui.toast(`Could not stop ${job.label}`)
268 }
269 }
270 const toggle = (job: Job) => update($, expanded, current => (current === job.id ? null : job.id))
271 const clear = () => update($, jobs, all => all.filter(job => job.status === 'running'))
272
273 const details = (job: Job) =>
274 open === job.id && (
275 <Box key={`details-${job.id}`} flexDirection="column" paddingLeft={4}>
276 {(job.recent ?? []).length === 0 && <Text dimColor>No tool calls seen yet.</Text>}
277 {(job.recent ?? []).map((line, i) => (
278 <Text key={`r-${i}`} dimColor>
279 {clip(line, width + 10)}
280 </Text>
281 ))}
282 {job.outcome !== undefined && <Text>{clip(`→ ${job.outcome}`, width + 10)}</Text>}
283 {job.kind === 'agent' && <Text dimColor italic>Full transcript: open it from the tasks list.</Text>}
284 </Box>
285 )
286
287 return (
288 <Box flexDirection="column">
289 {running.length === 0 && finished.length === 0 && <Text dimColor>Nothing running.</Text>}
290 {running.map(job => (
291 <Box key={job.id} flexDirection="column">
292 <Box>
293 <Text>● {clip(job.label)} </Text>
294 <Text dimColor>
295 {job.kind === 'shell' ? 'shell' : job.detail} · {formatElapsed(at - job.startedAt)}{' '}
296 </Text>
297 {job.kind === 'agent' && (
298 <Button key={`open-${job.id}`} label={open === job.id ? 'Hide' : 'Open'} onPress={() => toggle(job)} />
299 )}
300 <Button key={`stop-${job.id}`} label="Stop" onPress={() => stop(job)} />
301 </Box>
302 {open !== job.id && job.recent !== undefined && job.recent.length > 0 && (
303 <Text dimColor> ↳ {clip(job.recent.at(-1) ?? '')}</Text>
304 )}
305 {details(job)}
306 </Box>
307 ))}
308 {finished.map(job => (
309 <Box key={job.id} flexDirection="column">
310 <Box>
311 <Text dimColor>
312 {job.status === 'completed' ? '✓' : '✗'} {clip(job.label)} · {job.status === 'completed' ? '' : `${job.status} · `}
313 {formatElapsed((job.endedAt ?? at) - job.startedAt)}{' '}
314 </Text>
315 {job.kind === 'agent' && (
316 <Button key={`open-${job.id}`} label={open === job.id ? 'Hide' : 'Open'} onPress={() => toggle(job)} />
317 )}
318 </Box>
319 {details(job)}
320 </Box>
321 ))}
322 {finished.length > 0 && (
323 <Box>
324 <Button key="clear" label="Clear finished" onPress={clear} />
325 </Box>
326 )}
327 </Box>
328 )
329 })
330}
331types/index.d.ts 24 lines1export type JobStatus = 'running' | 'completed' | 'failed' | 'killed'
2
3export type Job = {
4 id: string
5 kind: 'agent' | 'shell'
6 label: string
7 detail?: string
8 startedAt: number
9 endedAt?: number
10 status: JobStatus
11 isBackground: boolean
12 stopId: string
13 /** What the agent did last, newest last; the last entry is what it is doing now. */
14 recent?: string[]
15 /** The first line of a finished agent's answer. */
16 outcome?: string
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'agent-jobs': { jobs: Job[]; now: number; expanded: string | null }
22 }
23}
24