SLOPSHOPPER

deadlines

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

newrowscommandtoaststatusprocess
v0.3.0no licenseupdated 2026-10-03richardcsuwandi/claude-mods/plugins/deadlines
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deadlines
› 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 › /ddl ⎿ deadlines: No deadlines. Usage: /ddl, /ddl add <name> <YYYY-MM-DD> [HH:MM] [zone], /ddl rm <name>. Zones: aoe utc pst pt ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

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
ModWhat it does
deadlinesLive deadline countdowns in the status line, /ddl to manage them
usage-barContext, 5h, 7d and cost bars above the prompt, plus an auto-compact nudge
context-barA stacked context-window bar, colored like /context
monitorA 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 monitor one is animated). Your terminal's font and theme will differ.


deadlines

deadlines preview

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

RegionZones
Anywhereaoe (UTC-12, "Anywhere on Earth"), utc, gmt, local (your machine)
US and Canadapst 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
Europewet west bst cet cest eet eest msk
Asiaist (India) pkt npt ict wib wita wit sgt hkt pht jst kst
Oceaniaawst acst aest aedt nzst nzdt
Any otheran 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 leftEmojiStatus glyphColor
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

  • Times shown are in your machine's timezone, read once when the session starts. A daylight-saving change mid-session is off by an hour until you restart.
  • If the timezone can't be read (for example on Windows), times show in UTC.

usage-bar

usage-bar preview

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.


context-bar

context-bar preview

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.


monitor

monitor preview

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:

RowMeaning
🤖 / ⚙️ blue name, sweeping bar, elapsed timea background agent or shell that is still running. The bar shows activity, not progress
✅ dim green donethe job finished. Stays until your next prompt, so you don't miss it
❌ red failed needs youthe job failed (non-zero exit, or the agent failed). Stays until your next prompt
⏹ amber stoppedthe 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
🧠 contexthow 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

  • Finished rows show no duration. The notification arrives when Claude is free to read it, not when the job ended, so any time would be misleading.
  • An agent that stops while its own background work is still running notifies once at that point, and again when it is really done. The row turns ✅ at the first notification, so it can read done slightly early.
  • Subagents' own tool calls aren't tracked, only the jobs the main session starts.
  • Inspired by leitstand (a control room for Claude Code). This is an independent implementation with a different look and no disk row.

Adding a mod

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.

Source 1 files
hooks/register.tsx 225 lines
1import 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