Pings your sites every 2 minutes; a red band above the prompt the moment one goes down, and /jruptime with latency sparklines and the last incident.

Watches your sites while you work. Nothing shows while everything is up; the moment one goes down, a red band appears above the prompt.
▲ api.example.com down 4 min · /jruptime details
One row on the left, only while there is something to act on.
/jruptime details shows every site:
jr uptime
● example.com 200 · 84 ms
▃▃▂▃▄▃▃▂▃▅▃▃▂▃▃▃█▃▃▂ last incident 3 h ago, 6 min
✕ api.example.com down 4 min
▂▂▃▂▂▃✕✕ no incident seen
| Command | |
|---|---|
/jruptime | Hide or show the band (it toggles); /jruptime hide and show set it |
/jruptime details | Check every site now and open the list |
/config)| Option | Default | |
|---|---|---|
| URLs to watch | Separated by commas; https:// is added when missing | |
| Show the band when all is up | off | On: a green dot per site the rest of the time |
/plugin install jr-uptime --marketplace jrlprost/claude-code-mods
Sends a GET request to each URL you list, every 2 minutes, while a session is open. Keeps the last incident per site in the plugin's own store. Writes nothing else, runs nothing.
hooks/register.tsx 180 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { JrUptimeSite } from '../types'
5import { gradientText } from './jr/draw'
6import { GRADIENTS } from './jr/gradient'
7import { addCheck, ago, duration, emptySite, isUp, label, latencySpark, parseUrls } from './uptime'
8
9const PANE = 'jr-uptime'
10const EVERY_MS = 2 * 60_000
11const TIMEOUT_MS = 10_000
12const sites = atom({ plugin: 'jr-uptime', key: 'sites' } as const, [])
13const bandHidden = atom({ plugin: 'jr-uptime', key: 'isBandHidden' } as const, false)
14
15// The band can be hidden: /jruptime hide, show, or the bare command to toggle. One choice for
16// every session, kept across restarts.
17const band = { isLoaded: false }
18
19const bandVerb = (args: string) => {
20 const arg = args.trim().toLowerCase()
21 if (arg === 'hide' || arg === 'off') return 'hide' as const
22 if (arg === 'show' || arg === 'on') return 'show' as const
23 return arg === '' ? ('toggle' as const) : null
24}
25
26async function isBandHidden($: EngineInterface) {
27 if (!band.isLoaded) {
28 band.isLoaded = true
29 if ((await $.store.get('isBandHidden')) === true) await update($, bandHidden, () => true)
30 }
31 return read($, bandHidden)
32}
33
34async function setBand($: EngineInterface, verb: 'hide' | 'show' | 'toggle') {
35 const hide = verb === 'toggle' ? !(await isBandHidden($)) : verb === 'hide'
36 await $.store.set('isBandHidden', hide)
37 await update($, bandHidden, () => hide)
38 return { text: hide ? 'jr uptime · band hidden (/jruptime to bring it back)' : 'jr uptime · band shown' }
39}
40const live = { isStarted: false, urls: [] as string[] }
41
42/** One request; a status, or null when it fails or takes longer than the timeout. */
43async function ping($: EngineInterface, url: string) {
44 const start = await $.clock.now()
45 const timeout = $.clock.sleep(TIMEOUT_MS).then(() => null)
46 try {
47 const res = await Promise.race([$.http.fetch(url, { method: 'GET', headers: { 'User-Agent': 'jr-uptime (Claude Code mod)' } }), timeout])
48 const end = await $.clock.now()
49 return { at: end, ms: end - start, status: res ? res.status : null }
50 } catch {
51 const end = await $.clock.now()
52 return { at: end, ms: end - start, status: null }
53 }
54}
55
56async function checkAll($: EngineInterface) {
57 if (live.urls.length === 0) return
58 const results = await Promise.all(live.urls.map(url => ping($, url)))
59 const saved = ((await $.store.get('incidents')) as Record<string, JrUptimeSite['lastIncident']> | undefined) ?? {}
60 await update($, sites, prev =>
61 live.urls.map((url, i) => {
62 const site = prev.find(s => s.url === url) ?? { ...emptySite(url), lastIncident: saved[url] ?? null }
63 return addCheck(site, results[i]!)
64 }),
65 )
66 const next = await read($, sites)
67 await $.store.set('incidents', Object.fromEntries(next.map(s => [s.url, s.lastIncident])))
68}
69
70async function ensureStarted($: EngineInterface) {
71 if (live.isStarted) return
72 live.isStarted = true
73 await $.command.register({ name: 'jruptime', description: 'Show or hide the uptime band; /jruptime details opens your sites with latency and last incident' })
74 if (live.urls.length === 0) return
75 await checkAll($)
76 $.clock.every(EVERY_MS, () => {
77 void checkAll($)
78 })
79}
80
81export const register: Register = (on, options) => {
82 live.urls = parseUrls(String(options.urls ?? ''))
83 const showWhenUp = options.showWhenUp === true
84
85 on('session.start', async ($, e, next) => {
86 const started = await next(e)
87 await ensureStarted($)
88 return started
89 })
90
91 on('prompt.submit', async ($, e, next) => {
92 await ensureStarted($)
93 return next(e)
94 })
95
96 on('command.run', { command: 'jruptime' }, async ($, e) => {
97 await ensureStarted($)
98 const verb = bandVerb(e.args)
99 // Not set up yet: the bare command says how, instead of toggling a band that cannot show.
100 if (verb && (live.urls.length > 0 || verb !== 'toggle')) return setBand($, verb)
101 if (live.urls.length === 0) return { text: 'jr uptime · add the URLs to watch in /config under jr-uptime (separated by commas).' }
102 await checkAll($)
103 await $.ui.open({ id: PANE, title: 'jr uptime', rows: Math.min(30, live.urls.length * 2 + 3) })
104 return { text: 'jr uptime opened.' }
105 })
106
107 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
108 const { Box, Text } = $.ui.resolve(e)
109 const list = await read($, sites)
110 const now = await $.clock.now()
111 const width = Math.max(30, ...list.map(s => label(s.url).length + 2))
112 return (
113 <Box flexDirection="column">
114 {gradientText(Text, 'jr uptime', GRADIENTS.heat)}
115 {list.length === 0 && <Text dimColor>Checking…</Text>}
116 {list.map(s => {
117 const lastCheck = s.history[s.history.length - 1]
118 const up = isUp(lastCheck?.status ?? null)
119 return (
120 <Box flexDirection="column">
121 <Text wrap="truncate-end">
122 <Text color={up ? '#2cb67d' : '#ef4565'}>{up ? '● ' : '✕ '}</Text>
123 <Text bold>{label(s.url).padEnd(width)}</Text>
124 <Text color={up ? '#2cb67d' : '#ef4565'}>{up ? `${lastCheck?.status} · ${lastCheck?.ms} ms` : `down ${s.downSince !== null ? duration(now - s.downSince) : ''}`}</Text>
125 </Text>
126 <Text wrap="truncate-end">
127 <Text>{' '}</Text>
128 <Text color="#3da9fc">{latencySpark(s.history)}</Text>
129 <Text dimColor>{s.lastIncident ? ` last incident ${ago(now - s.lastIncident.to)}, ${duration(s.lastIncident.to - s.lastIncident.from)}` : ' no incident seen'}</Text>
130 </Text>
131 </Box>
132 )
133 })}
134 </Box>
135 )
136 })
137
138 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
139 const below = await next(e)
140 // Never write state while drawing: the saved choice is loaded just after.
141 if (!band.isLoaded) {
142 $.clock.after(0, () => {
143 void isBandHidden($)
144 })
145 }
146 if (await read($, bandHidden)) return below
147 const list = await read($, sites)
148 if (list.length === 0 || e.props.hasSurvey) return below
149 const down = list.filter(s => !isUp(s.history[s.history.length - 1]?.status ?? null))
150 if (down.length === 0 && !showWhenUp) return below
151 const now = await $.clock.now()
152 const { Box, Text } = $.ui.resolve(e)
153 return (
154 <Box flexDirection="column">
155 {down.length > 0 ? (
156 <Text wrap="truncate-end">
157 <Text color="#ef4565">{'▲ '}</Text>
158 {down.map((s, i) => (
159 <Text>
160 {i > 0 ? <Text dimColor>{' · '}</Text> : ''}
161 <Text color="#ef4565" bold>{label(s.url)}</Text>
162 <Text>{s.downSince === null ? ' down' : ` down ${duration(now - s.downSince)}`}</Text>
163 </Text>
164 ))}
165 <Text dimColor>{' · /jruptime details'}</Text>
166 </Text>
167 ) : (
168 <Box justifyContent="flex-end">
169 <Text wrap="truncate-end">
170 <Text dimColor>{'uptime '}</Text>
171 <Text color="#2cb67d">{`${list.length}/${list.length} up`}</Text>
172 </Text>
173 </Box>
174 )}
175 {below}
176 </Box>
177 )
178 })
179}
180hooks/jr/draw.tsx 35 lines1// Drawing helpers shared by every jr mod. Synced into each plugin's hooks/jr/.
2import type { Elements } from 'claude-code'
3
4import { bar, paint } from './gradient'
5
6type TextElement = Elements['terminal']['Text']
7
8/** `text` in a gradient, one color per character, bold by default. */
9export const gradientText = (Text: TextElement, text: string, stops: readonly string[], bold = true) => (
10 <Text bold={bold}>
11 {paint(text, stops).map(({ char, color }) => (
12 <Text color={color}>{char}</Text>
13 ))}
14 </Text>
15)
16
17/** A gauge: filled cells in the gradient, empty cells dim. */
18export const gauge = (Text: TextElement, ratio: number, width: number, stops: readonly string[]) => (
19 <Text>
20 {bar(ratio, width, stops).map(cell =>
21 cell.empty ? (
22 <Text color={cell.color} dimColor>
23 {cell.char}
24 </Text>
25 ) : (
26 <Text color={cell.color}>{cell.char}</Text>
27 ),
28 )}
29 </Text>
30)
31
32/** The jr tag that opens every band line: a small gradient label. */
33export const tag = (Text: TextElement, label: string, stops: readonly string[]) =>
34 gradientText(Text, `${label} `, stops)
35hooks/jr/gradient.ts 75 lines1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7 const n = Number.parseInt(hex.replace('#', ''), 16)
8 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12 `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15 // The jr signature: violet to pink to amber.
16 brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17 // Calm to alarm, for gauges where full is bad.
18 heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19 // Cool, for neutral figures.
20 ice: ['#3da9fc', '#7f5af0'],
21 // Money and growth.
22 money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29 if (stops.length === 1) return stops[0]!
30 const clamped = Math.min(1, Math.max(0, t))
31 const span = clamped * (stops.length - 1)
32 const i = Math.min(stops.length - 2, Math.floor(span))
33 const a = hexToRgb(stops[i]!)
34 const b = hexToRgb(stops[i + 1]!)
35 const local = span - i
36 return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41 const chars = [...text]
42 return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53 const filled = Math.min(1, Math.max(0, ratio)) * width
54 const full = Math.floor(filled)
55 const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56 const cells: { char: string; color: string; empty?: true }[] = []
57 for (let i = 0; i < width; i++) {
58 const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59 if (i < full) cells.push({ char: '█', color })
60 else if (i === full && partial) cells.push({ char: partial, color })
61 else cells.push({ char: '·', color, empty: true })
62 }
63 return cells
64}
65
66export const formatTokens = (n: number) =>
67 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70 const minutes = Math.max(0, Math.round(ms / 60_000))
71 const h = Math.floor(minutes / 60)
72 const m = minutes % 60
73 return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75hooks/uptime.ts 48 lines1import type { JrUptimeCheck, JrUptimeSite } from '../types'
2
3export const HISTORY = 30
4
5/** URLs from the setting: separated by commas, spaces or new lines; `https://` added when missing. */
6export const parseUrls = (raw: string) =>
7 [...new Set(raw.split(/[\s,]+/).map(s => s.trim()).filter(Boolean).map(u => (/^https?:\/\//i.test(u) ? u : `https://${u}`)))]
8
9/** Up when the server answers below 400 in time. */
10export const isUp = (status: number | null) => status !== null && status < 400
11
12export const label = (url: string) => url.replace(/^https?:\/\//, '').replace(/\/$/, '')
13
14/** Adds a check to a site: history capped, incident opened on the first failure and closed on recovery. */
15export const addCheck = (site: JrUptimeSite, check: JrUptimeCheck): JrUptimeSite => {
16 const history = [...site.history, check].slice(-HISTORY)
17 const up = isUp(check.status)
18 let { downSince, lastIncident } = site
19 if (!up && downSince === null) downSince = check.at
20 if (up && downSince !== null) {
21 lastIncident = { from: downSince, to: check.at }
22 downSince = null
23 }
24 return { ...site, history, downSince, lastIncident }
25}
26
27export const emptySite = (url: string): JrUptimeSite => ({ url, history: [], downSince: null, lastIncident: null })
28
29const SPARK = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
30/** Latency sparkline; a failed check is drawn as ✕. */
31export const latencySpark = (history: readonly JrUptimeCheck[]) => {
32 const most = Math.max(1, ...history.filter(c => isUp(c.status)).map(c => c.ms))
33 return history.map(c => (isUp(c.status) ? SPARK[Math.min(7, Math.round((c.ms / most) * 7))]! : '✕')).join('')
34}
35
36export const ago = (ms: number) => {
37 const m = Math.round(ms / 60_000)
38 if (m < 1) return 'just now'
39 if (m < 60) return `${m} min ago`
40 const h = Math.round(m / 60)
41 return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`
42}
43
44export const duration = (ms: number) => {
45 const m = Math.max(1, Math.round(ms / 60_000))
46 return m < 60 ? `${m} min` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
47}
48types/index.d.ts 16 lines1/** One request: when it ended, how long it took, the status (null: failed or timed out). */
2export type JrUptimeCheck = { at: number; ms: number; status: number | null }
3
4export type JrUptimeSite = {
5 url: string
6 history: JrUptimeCheck[]
7 downSince: number | null
8 lastIncident: { from: number; to: number } | null
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'jr-uptime': { sites: JrUptimeSite[]; isBandHidden: boolean }
14 }
15}
16