SLOPSHOPPER

jr-uptime

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.

newpanebandcommandpromptnetwork
★ 2v0.3.0MITupdated 2026-10-16jrlprost/claude-code-mods/plugins/jr-uptime
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-uptime
│ ┃ jr-uptime ✕ › fix the failing auth test and add an audit log call │ ┃ jr uptime │ ┃ Checking… ⏺ 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 │ │ › /jruptime │ ⎿ jr-uptime: jr uptime · add the URLs to watch in /config under jr │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr-uptime
jr uptime Checking…
README

jr-uptime

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
  • Every site is requested every 2 minutes; a status below 400 within 10 seconds counts as up.
  • The sparkline shows the latency of the last 30 checks, ✕ for a failed one.
  • The last incident of each site is remembered across sessions.

Commands

Command
/jruptimeHide or show the band (it toggles); /jruptime hide and show set it
/jruptime detailsCheck every site now and open the list

Settings (/config)

OptionDefault
URLs to watchSeparated by commas; https:// is added when missing
Show the band when all is upoffOn: a green dot per site the rest of the time

Install

/plugin install jr-uptime --marketplace jrlprost/claude-code-mods

Access

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.

Source 5 files
hooks/register.tsx 180 lines
1import { 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}
180
hooks/jr/draw.tsx 35 lines
1// 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)
35
hooks/jr/gradient.ts 75 lines
1// 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}
75
hooks/uptime.ts 48 lines
1import 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}
48
types/index.d.ts 16 lines
1/** 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