Live deadline countdowns in the Claude Code status line. /ddl to list, add, remove.

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 225 lines1import type { EngineInterface, Register } from 'claude-code'
2
3type Deadline = { name: string; at: number } // at: epoch ms
4
5const HOUR = 3_600_000
6const SEED: Deadline[] = []
7const USAGE = 'Usage: /ddl, /ddl add <name> <YYYY-MM-DD> [HH:MM] [zone], /ddl rm <name>. Zones: aoe utc pst pt est et cet wib jst sgt ist ... or +08:00'
8
9// Fixed UTC offsets in minutes. Daylight-saving zones come as pairs (est/edt), or as et/ct/mt/pt
10// which pick the US rule for the deadline's date.
11// ponytail: `ist` is India and `cst` US Central unless it matches your machine's own label.
12const ZONES: Record<string, number> = {
13 aoe: -720, utc: 0, gmt: 0, z: 0,
14 hst: -600, akst: -540, akdt: -480, pst: -480, pdt: -420, mst: -420, mdt: -360,
15 cst: -360, cdt: -300, est: -300, edt: -240, ast: -240, nst: -210,
16 wet: 0, west: 60, bst: 60, cet: 60, cest: 120, eet: 120, eest: 180, msk: 180,
17 ist: 330, pkt: 300, npt: 345, ict: 420, wib: 420, wita: 480, wit: 540,
18 sgt: 480, hkt: 480, pht: 480, awst: 480, jst: 540, kst: 540, acst: 570,
19 aest: 600, aedt: 660, nzst: 720, nzdt: 780,
20}
21const US_AUTO: Record<string, [number, number]> = { et: [-300, -240], ct: [-360, -300], mt: [-420, -360], pt: [-480, -420] }
22
23// US daylight saving: second Sunday of March to first Sunday of November (the 2am edge is ignored).
24function isUsDst(y: number, mo: number, d: number): boolean {
25 const sunday = (month: number, nth: number) => {
26 const first = new Date(Date.UTC(y, month - 1, 1)).getUTCDay()
27 return 1 + ((7 - first) % 7) + 7 * (nth - 1)
28 }
29 const day = mo * 100 + d
30 return day >= 300 + sunday(3, 2) && day < 1100 + sunday(11, 1)
31}
32
33// Offset in minutes for a zone word typed after the date, or undefined when it isn't one.
34function zoneOffset(word: string, y: number, mo: number, d: number, local: { offset: number; label: string }): number | undefined {
35 const w = word.toLowerCase()
36 if (w === 'local' || w === local.label.toLowerCase()) return local.offset / 60_000
37 if (w in US_AUTO) return US_AUTO[w][isUsDst(y, mo, d) ? 1 : 0]
38 if (w in ZONES) return ZONES[w]
39 const num = w.match(/^(?:utc|gmt)?([+-])(\d{1,2})(?::?(\d{2}))?$/)
40 if (!num) return undefined
41 const mins = +num[2] * 60 + +(num[3] ?? 0)
42 return num[1] === '-' ? -mins : mins
43}
44
45const fmtOffset = (m: number) => `UTC${m < 0 ? '-' : '+'}${String(Math.floor(Math.abs(m) / 60)).padStart(2, '0')}:${String(Math.abs(m) % 60).padStart(2, '0')}`
46
47// The module has no reliable timezone, so ask the host once.
48// ponytail: offset read at session start, a DST switch mid-session is off by 1h until restart.
49let tz = { offset: 0, label: 'UTC' }
50
51async function readTimezone($: EngineInterface) {
52 const { stdout } = await $.process.run(['date', '+%z %Z']).catch(() => ({ stdout: '+0000 UTC' }))
53 const [z, label] = stdout.trim().split(' ')
54 if (!/^[+-]\d{4}$/.test(z)) return
55 const sign = z.startsWith('-') ? -1 : 1
56 tz = { offset: sign * (+z.slice(1, 3) * HOUR + +z.slice(3, 5) * 60_000), label: label || z }
57}
58
59// Deadlines live in $.store (Claude Code's per-plugin file) and are mirrored to a file of
60// our own, so a store that comes back empty (marketplace renamed, file rewritten on update)
61// is restored instead of silently overwritten by the next `add`.
62const isList = (v: unknown): v is Deadline[] =>
63 Array.isArray(v) && v.every(d => d && typeof d.name === 'string' && Number.isFinite(d.at))
64
65async function mirrorPath($: EngineInterface): Promise<string | undefined> {
66 const home = await $.env.get('HOME')
67 return home ? `${home}/.claude/${$.plugin.name}-backup.json` : undefined
68}
69
70async function readMirror($: EngineInterface): Promise<Deadline[] | undefined> {
71 try {
72 const path = await mirrorPath($)
73 const parsed = path ? JSON.parse(await $.fs.read(path)) : undefined
74 return isList(parsed) ? parsed : undefined
75 } catch {
76 return undefined
77 }
78}
79
80let mirrorChecked = false // only look at the mirror once per load while the store is empty
81
82async function load($: EngineInterface): Promise<Deadline[]> {
83 const stored = await $.store.get('deadlines')
84 if (isList(stored)) return stored
85
86 if (stored !== undefined) await $.store.set('deadlines.corrupt', stored) // keep it, never overwrite blindly
87 if (mirrorChecked) return SEED
88 mirrorChecked = true
89
90 const mirror = await readMirror($)
91 if (mirror && mirror.length > 0) {
92 await $.store.set('deadlines', mirror)
93 $.ui.toast(`deadlines: restored ${mirror.length} from backup`)
94 return mirror
95 }
96 return SEED
97}
98
99async function save($: EngineInterface, list: Deadline[]) {
100 await $.store.set('deadlines', list)
101 try {
102 const path = await mirrorPath($)
103 if (path) await $.fs.write(path, JSON.stringify(list, null, 2))
104 } catch {
105 // ponytail: mirror is best effort, the store stays the source of truth
106 }
107}
108
109function left(ms: number): string {
110 const m = Math.floor(ms / 60_000)
111 return `${Math.floor(m / 1440)}d ${Math.floor((m % 1440) / 60)}h ${m % 60}m`
112}
113
114const glyph = (ms: number) => (ms < 86_400_000 ? '●' : ms < 7 * 86_400_000 ? '◐' : '○')
115const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
116
117const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
118
119function local(at: number): string {
120 const d = new Date(at + tz.offset)
121 const hm = d.toISOString().slice(11, 16)
122 return `${DAYS[d.getUTCDay()]} ${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()} ${hm} ${tz.label}`
123}
124
125const DAY = 86_400_000
126const tone = (ms: number) => (ms < DAY ? 'error' : ms < 7 * DAY ? 'warning' : 'success')
127const mood = (ms: number) => (ms < DAY ? '🔥' : ms < 3 * DAY ? '😬' : ms < 7 * DAY ? '⏳' : ms < 30 * DAY ? '🗓 ' : '🌱')
128
129// Fills up as the deadline nears, over a 30-day window.
130function heat(ms: number, width = 16): string {
131 const n = Math.round(width * (1 - Math.min(ms, 30 * DAY) / (30 * DAY)))
132 return '━'.repeat(n) + '╌'.repeat(width - n)
133}
134
135async function refresh($: EngineInterface) {
136 const now = await $.clock.now()
137 const next = (await load($)).filter(d => d.at > now).sort((a, b) => a.at - b.at)
138 if (next.length === 0) return $.ui.status(undefined)
139 const [first] = next
140 const more = next.length > 1 ? ` +${next.length - 1}` : ''
141 $.ui.status(`${glyph(first.at - now)} ${first.name} ${left(first.at - now)}${more}`)
142}
143
144export const register: Register = on => {
145 on('session.start', async ($, e, next) => {
146 await readTimezone($)
147 await $.command.register({ name: 'ddl', description: 'Deadlines: list, add <name> <YYYY-MM-DD> [HH:MM] [zone], rm <name>' })
148 await save($, await load($)) // restores from the mirror if needed, and backfills the mirror
149 $.clock.every(15_000, () => void refresh($))
150 await refresh($)
151 return next(e)
152 })
153
154 on('command.run', { command: 'ddl' }, async ($, e) => {
155 const list = await load($)
156 const add = e.args.match(/^\s*add\s+(.+?)\s+(\d{4})-(\d{2})-(\d{2})(?:\s+(\d{1,2}):(\d{2}))?(?:\s+(\S+))?\s*$/i)
157 const rm = e.args.match(/^\s*rm\s+(.+?)\s*$/)
158
159 if (add) {
160 const [, name, y, mo, d, hh = '23', mm = '59', zone] = add
161 const mins = zone === undefined ? tz.offset / 60_000 : zoneOffset(zone, +y, +mo, +d, tz)
162 if (mins === undefined) return { text: `Unknown time zone "${zone}". ${USAGE}` }
163 const at = Date.UTC(+y, +mo - 1, +d, +hh, +mm) - mins * 60_000
164 await save($, [...list.filter(x => x.name !== name), { name, at }])
165 await refresh($)
166 const entered = zone === undefined ? '' : ` (${y}-${mo}-${d} ${hh.padStart(2, '0')}:${mm} ${zone.toUpperCase()}, ${fmtOffset(mins)})`
167 return { text: `Added ${name}: ${local(at)}${entered}` }
168 }
169 if (rm) {
170 const kept = list.filter(x => x.name.toLowerCase() !== rm[1].toLowerCase())
171 await save($, kept)
172 await refresh($)
173 return { text: kept.length < list.length ? `Removed ${rm[1]}` : `No deadline named ${rm[1]}` }
174 }
175 if (e.args.trim()) return { text: USAGE }
176
177 const now = await $.clock.now()
178 const rows = [...list]
179 .sort((a, b) => a.at - b.at)
180 .map(d => {
181 const state = d.at > now ? `${glyph(d.at - now)} ${left(d.at - now)}` : '✓ passed'
182 return `${d.name}: ${local(d.at)}, ${state}`
183 })
184 return { text: rows.length ? rows.join('\n') : `No deadlines. ${USAGE}` }
185 })
186
187 // Draw the bare /ddl list as a card instead of the plain text row.
188 on('ui.render', { component: 'CommandOutput', props: { command: 'ddl' } }, async ($, e, next) => {
189 if (e.props.args.trim() || e.props.isErrored) return next(e)
190 const { Box, Text } = $.ui.resolve(e)
191 const now = await $.clock.now()
192 const list = [...(await load($))].sort((a, b) => a.at - b.at)
193 const nameWidth = Math.min(24, Math.max(8, ...list.map(d => d.name.length)) + 2)
194
195 return (
196 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
197 <Text bold>Deadlines</Text>
198 <Text> </Text>
199 {list.length === 0 && <Text dimColor>{USAGE}</Text>}
200 {list.map(d => {
201 const ms = d.at - now
202 if (ms <= 0) {
203 return (
204 <Box key={d.name}>
205 <Text dimColor>✅ </Text>
206 <Box width={nameWidth}><Text dimColor strikethrough>{d.name}</Text></Box>
207 <Text dimColor>done {local(d.at)}</Text>
208 </Box>
209 )
210 }
211 return (
212 <Box key={d.name}>
213 <Text>{mood(ms)} </Text>
214 <Box width={nameWidth}><Text bold color={tone(ms)} wrap="truncate-end">{d.name}</Text></Box>
215 <Box width={14}><Text bold>{left(ms)}</Text></Box>
216 <Text color={tone(ms)}>{heat(ms)}</Text>
217 <Text dimColor> {local(d.at)}</Text>
218 </Box>
219 )
220 })}
221 </Box>
222 )
223 })
224}
225