A control room above the prompt: background agents and shells (running, done, failed), blocked tool calls and context usage. /monitor pins it.

Small Claude Code mods I use every day. Each one is a few dozen lines of TypeScript and installs like any plugin.
/plugin marketplace add richardcsuwandi/claude-mods
/plugin install <mod>@claude-mods
/reload-plugins
| Mod | What it does |
|---|---|
deadlines | Live deadline countdowns in the status line, /ddl to manage them |
usage-bar | Context, 5h, 7d and cost bars above the prompt, plus an auto-compact nudge |
context-bar | A stacked context-window bar, colored like /context |
monitor | A control room above the prompt: background agents and shells, blocked tool calls, context |
Mods run with the same access to your machine as Claude Code itself and aren't sandboxed. Read the source (each is one file in plugins/<mod>/hooks/) before you install.
The images below are previews drawn from each mod's real output format and color thresholds, not captures of a live session (the
monitorone is animated). Your terminal's font and theme will differ.
Keeps your upcoming deadlines in the status line and shows all of them on demand. The status line holds the next one with a live countdown (days, hours, minutes, refreshed every 15 seconds), and +N counts the rest.
/ddl list everything as a card
/ddl add AISTATS 2026-10-06 aoe add a deadline (time defaults to 23:59)
/ddl add Group meeting 2026-10-16 14:00 no zone = your local time
/ddl add ICML 2027-01-28 23:59 pst any zone from the table below
/ddl add Thesis 2026-12-01 17:00 wib Indonesia (WIB, WITA, WIT all work)
/ddl add Review 2026-11-03 09:00 +05:30 or a plain UTC offset (also utc-5, gmt+7)
/ddl rm AISTATS remove one
Whatever zone you type, the deadline is stored as an exact moment and shown in your own local time, so the countdown is always right. The confirmation echoes both, for example Added ICML: Fri Jan 29 12:00 CST (2027-01-28 23:59 PST, UTC-08:00).
Time zones
| Region | Zones |
|---|---|
| Anywhere | aoe (UTC-12, "Anywhere on Earth"), utc, gmt, local (your machine) |
| US and Canada | pst pdt mst mdt cst cdt est edt akst akdt hst ast nst, plus pt mt ct et, which pick standard or daylight time from the deadline's date |
| Europe | wet west bst cet cest eet eest msk |
| Asia | ist (India) pkt npt ict wib wita wit sgt hkt pht jst kst |
| Oceania | awst acst aest aedt nzst nzdt |
| Any other | an offset such as +08:00, -0530, +7, utc+7, gmt-5 |
Some abbreviations are ambiguous. cst means your own zone if that's what your machine reports (so it is China Standard Time in Shanghai) and US Central otherwise. ist is India. For anything else, use an offset. An unknown zone is refused with a message and nothing is saved.
pt, et, ct and mt follow the US rule (second Sunday of March to first Sunday of November). cet is always UTC+1, so use cest for European summer deadlines.
How to read it
| Time left | Emoji | Status glyph | Color |
|---|---|---|---|
| over 30 days | 🌱 | ○ | green |
| 7 to 30 days | 🗓 | ○ | green |
| 3 to 7 days | ⏳ | ◐ | amber |
| 1 to 3 days | 😬 | ◐ | amber |
| under 1 day | 🔥 | ● | red |
The bar in each row fills up over the last 30 days. Deadlines that have passed stay in the list, greyed out with a ✅, until you rm them. Names can contain spaces.
Where your deadlines are saved
In Claude Code's per-plugin store (~/.claude/plugins/store/deadlines_<marketplace>-<hash>.json). The file name depends on the plugin and marketplace names only, so updating the plugin keeps your list. A second copy is mirrored to ~/.claude/deadlines-backup.json: if the store ever comes back empty or unreadable, /ddl restores from that copy instead of overwriting it, and keeps the unreadable value under deadlines.corrupt. Renaming the marketplace creates a fresh store, which is what the backup covers. Persistence tests and time zone parsing live in plugins/deadlines/tests/ (run them with claude plugin test plugins/deadlines).
Notes
Three bars above the prompt: how full the context window is (ctx), and your 5-hour and 7-day usage limits, followed by the session cost in dollars. Bars turn amber at 70% and red at 90%. The set of limit bars depends on what your plan reports.
Auto-compact nudge. When the context passes 85% a toast tells you once to run /compact, and a Compact button appears next to the bars. It re-arms after the context drops back under 75%, so a compact doesn't silence the next warning. The nudge keeps working even while the bars are hidden.
/usage-bar toggle the bars (on by default)
Updates after every turn.
One stacked bar showing what's filling the context window: system prompt, tools, memory files, messages, with the free space and the auto-compact buffer shown as dim blocks at the end. The colors match the ones /context uses, and a legend underneath lists each category with its size in tokens.
/context-bar toggle the bar (off by default)
Updates after every turn using Claude Code's local token estimates, so it makes no extra API calls and costs no tokens. The bar resizes to your terminal width.
The categories in the preview are illustrative: yours come from your own session.
A small control room above your prompt for everything Claude is doing in the background. When Claude starts a background agent or a shell with run_in_background, a rounded card appears with one row per job:
| Row | Meaning |
|---|---|
| 🤖 / ⚙️ blue name, sweeping bar, elapsed time | a background agent or shell that is still running. The bar shows activity, not progress |
✅ dim green done | the job finished. Stays until your next prompt, so you don't miss it |
❌ red failed needs you | the job failed (non-zero exit, or the agent failed). Stays until your next prompt |
⏹ amber stopped | the job was killed, by you or by Claude |
🚫 amber Bash blocked: ... | a hook or permission refused a tool call, with the reason. Cleared at your next prompt |
🧠 context | how full the context window is: amber at 70%, red at 90%, and compact from 85% as a reminder to run /compact |
The header counts them (Monitor 1 running 1 done 1 failed). With nothing running and the context low, the card doesn't draw at all, so it never takes space when idle. It stacks with the bars above if you use usage-bar or context-bar too.
/monitor pin the card so the context row is always visible; run again to unpin
(unpinning also clears finished jobs)
Try it. Ask Claude: "Run sleep 30 and sleep 45 as background jobs, and start one tiny haiku subagent that sleeps 20 seconds." Add exit 1 to one of them to see a failure.
How it works. It watches Agent and Bash calls from the main session. A shell counts as a job when its result carries a background task id, and an agent when it comes back async_launched. A job ends when Claude Code delivers the task notification for its id, and the notification's status (completed, failed, killed) picks the row. Tests: claude plugin test plugins/monitor.
Notes
Put it in plugins/<name>/ (manifest in .claude-plugin/plugin.json, hooks in hooks/), add an entry to .claude-plugin/marketplace.json, run claude plugin validate, and push.
hooks/register.tsx 187 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Block, Ctx, Job } from '../types'
5
6const jobs = atom({ plugin: 'monitor', key: 'jobs' } as const, [] as Job[])
7const ctx = atom({ plugin: 'monitor', key: 'ctx' } as const, null as Ctx | null)
8const blocked = atom({ plugin: 'monitor', key: 'blocked' } as const, [] as Block[])
9const isPinned = atom({ plugin: 'monitor', key: 'isPinned' } as const, false)
10const tick = atom({ plugin: 'monitor', key: 'tick' } as const, 0)
11
12const NUDGE_AT = 85
13const WIDTH = 10
14const COLORS = { running: '#6cb6ff', done: '#8fd694', failed: '#f2777a', stopped: '#f5c06b' }
15
16const hue = (p: number) => (p >= 90 ? COLORS.failed : p >= 70 ? COLORS.stopped : COLORS.done)
17const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k` : `${n}`)
18const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
19
20function elapsed(ms: number): string {
21 const s = Math.max(0, Math.round(ms / 1000))
22 if (s < 60) return `${s}s`
23 if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
24 return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
25}
26
27const field = (text: string, tag: string) => text.match(new RegExp(`<${tag}>\\s*([^<]*?)\\s*</${tag}>`))?.[1]
28
29async function refreshCtx($: EngineInterface) {
30 const { context } = await $.session.usage()
31 if (context.percent === undefined) return
32 await update($, ctx, () => ({ percent: context.percent ?? 0, tokens: context.tokens, window: context.window }))
33}
34
35// Close a job when its task notification arrives (status: completed, failed or killed).
36async function settle($: EngineInterface, text: string) {
37 const taskId = field(text, 'task-id')
38 const toolUseId = field(text, 'tool-use-id')
39 const status = field(text, 'status') ?? 'completed'
40 const now = await $.clock.now()
41 const state = status === 'completed' ? 'done' : status === 'killed' ? 'stopped' : 'failed'
42 await update($, jobs, list =>
43 list.map(j =>
44 j.state === 'running' && ((taskId && j.taskId === taskId) || (toolUseId && j.id === toolUseId))
45 ? { ...j, state, endedAt: now }
46 : j,
47 ),
48 )
49}
50
51export const register: Register = on => {
52 on('session.start', async ($, e, next) => {
53 await $.command.register({ name: 'monitor', description: 'Pin or unpin the monitor above the prompt (unpinning clears finished jobs)' })
54 $.clock.every(1000, async () => {
55 if ((await read($, jobs)).some(j => j.state === 'running')) await update($, tick, n => n + 1)
56 })
57 await refreshCtx($)
58 return next(e)
59 })
60
61 on('command.run', { command: 'monitor' }, async $ => {
62 const now = !(await read($, isPinned))
63 await update($, isPinned, () => now)
64 if (now) {
65 await refreshCtx($)
66 } else {
67 await update($, jobs, list => list.filter(j => j.state === 'running'))
68 await update($, blocked, () => [])
69 }
70 return { text: now ? 'Monitor pinned.' : 'Monitor unpinned, finished jobs cleared.' }
71 })
72
73 on('tool.call', async ($, e, next) => {
74 const any = e as any
75 const isJob = !any.agentId && (e.tool === 'Agent' || (e.tool === 'Bash' && any.run_in_background === true))
76 if (isJob) {
77 const now = await $.clock.now()
78 const name = (any.description || any.name || any.command || e.tool) as string
79 const job: Job = { id: e.tool_use_id, name: clip(name.split('\n')[0], 40), kind: e.tool === 'Agent' ? 'agent' : 'shell', startedAt: now, state: 'running' }
80 await update($, jobs, list => [...list, job])
81 }
82
83 const ran = await next(e)
84 const drop = () => update($, jobs, list => list.filter(j => j.id !== e.tool_use_id))
85
86 if (ran.deny !== undefined) {
87 if (isJob) await drop()
88 if (!any.agentId) await update($, blocked, list => [...list, { tool: e.tool, reason: clip(ran.deny.split('\n')[0], 120) }].slice(-3))
89 } else if (isJob) {
90 const result = ran.result as any
91 const taskId: string | undefined = e.tool === 'Agent' ? (result?.status === 'async_launched' ? result.agentId : undefined) : result?.backgroundTaskId
92 if (ran.isError || !taskId) await drop() // ran in the foreground or never started: not a background job
93 else await update($, jobs, list => list.map(j => (j.id === e.tool_use_id ? { ...j, taskId } : j)))
94 }
95 return ran
96 })
97
98 on('prompt.submit', async ($, e, next) => {
99 if (e.origin.kind === 'task-notification') {
100 await settle($, e.text)
101 } else if (e.origin.kind === 'composer') {
102 await update($, jobs, list => list.filter(j => j.state === 'running')) // finished jobs stay until your next prompt
103 await update($, blocked, () => [])
104 }
105 return next(e)
106 })
107
108 on('turn.complete', async ($, e, next) => {
109 await refreshCtx($)
110 return next(e)
111 })
112
113 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
114 if (e.props.hasSurvey) return next(e)
115 const list = await read($, jobs)
116 const c = await read($, ctx)
117 const stops = await read($, blocked)
118 const t = await read($, tick)
119 const pinned = await read($, isPinned)
120 const isFull = (c?.percent ?? 0) >= NUDGE_AT
121 if (list.length === 0 && stops.length === 0 && !pinned && !isFull) return next(e)
122
123 const below = await next(e) // other bands stack under this one
124 const { Box, Text } = $.ui.resolve(e)
125 const now = await $.clock.now()
126 const count = (s: string) => list.filter(j => j.state === s).length
127 const nameW = Math.min(28, Math.max(10, ...list.map(j => j.name.length)) + 2)
128
129 return (
130 <Box flexDirection="column">
131 {below}
132 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
133 <Text>
134 <Text bold>Monitor</Text>
135 {count('running') > 0 && <Text color={COLORS.running}> {count('running')} running</Text>}
136 {count('done') > 0 && <Text color={COLORS.done}> {count('done')} done</Text>}
137 {count('failed') > 0 && <Text color={COLORS.failed}> {count('failed')} failed</Text>}
138 {count('stopped') > 0 && <Text color={COLORS.stopped}> {count('stopped')} stopped</Text>}
139 </Text>
140 {list.map(j => {
141 const color = COLORS[j.state]
142 const icon = j.state === 'running' ? (j.kind === 'agent' ? '🤖' : '⚙️ ') : j.state === 'done' ? '✅' : j.state === 'failed' ? '❌' : '⏹ '
143 const pos = (t % (WIDTH + 3)) - 2 // a short bright run sweeps along the bar: activity, not progress
144 return (
145 <Box key={j.id}>
146 <Text>{icon} </Text>
147 <Box width={nameW}>
148 <Text bold={j.state !== 'done'} dimColor={j.state === 'done'} color={color} wrap="truncate-end">{j.name}</Text>
149 </Box>
150 {j.state === 'running' ? (
151 <Text>
152 {Array.from({ length: WIDTH }, (_, i) => (
153 <Text key={i} color={i >= pos && i < pos + 3 ? color : undefined} dimColor={!(i >= pos && i < pos + 3)}>{i >= pos && i < pos + 3 ? '▰' : '▱'}</Text>
154 ))}
155 <Text dimColor> {elapsed(now - j.startedAt)}</Text>
156 </Text>
157 ) : (
158 <Text color={color} dimColor={j.state === 'done'}>
159 {/* no duration here: the notification arrives when Claude is free, not when the job ended */}
160 {j.state === 'done' ? 'done' : j.state === 'failed' ? 'failed needs you' : 'stopped'}
161 </Text>
162 )}
163 </Box>
164 )
165 })}
166 {stops.map((b, i) => (
167 <Text key={i} color={COLORS.stopped} wrap="truncate-end">🚫 {b.tool} blocked: {b.reason}</Text>
168 ))}
169 {c && (
170 <Box>
171 <Text>🧠 </Text>
172 <Box width={nameW}><Text dimColor>context</Text></Box>
173 <Text>
174 <Text color={hue(c.percent)}>{'█'.repeat(Math.min(WIDTH, Math.round((c.percent / 100) * WIDTH)))}</Text>
175 <Text dimColor>{'░'.repeat(WIDTH - Math.min(WIDTH, Math.round((c.percent / 100) * WIDTH)))}</Text>
176 <Text color={hue(c.percent)}> {Math.round(c.percent)}%</Text>
177 {c.tokens !== undefined && <Text dimColor> {k(c.tokens)}/{k(c.window)}</Text>}
178 {c.percent >= NUDGE_AT && <Text color={COLORS.stopped}> compact</Text>}
179 </Text>
180 </Box>
181 )}
182 </Box>
183 </Box>
184 )
185 })
186}
187types/index.d.ts 19 lines1export type JobState = 'running' | 'done' | 'failed' | 'stopped'
2export type Job = {
3 id: string // the tool_use_id of the call that started it
4 taskId?: string // the id the task notification names
5 name: string
6 kind: 'agent' | 'shell'
7 startedAt: number
8 endedAt?: number
9 state: JobState
10}
11export type Ctx = { percent: number; tokens?: number; window: number }
12export type Block = { tool: string; reason: string }
13
14declare module 'claude-code' {
15 interface PluginState {
16 monitor: { jobs: Job[]; ctx: Ctx | null; blocked: Block[]; isPinned: boolean; tick: number }
17 }
18}
19