SLOPSHOPPER

jr-outage

Is it them or is it me? The status of Claude, GitHub, npm and Vercel, checked every 10 minutes: /jroutage shows them all, a band and a toast appear only when…

newpanebandcommandtoastnetwork
★ 2v0.3.0MITupdated 2026-10-16jrlprost/claude-code-mods/plugins/jr-outage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-outage
│ ┃ jr-outage ✕ › fix the failing auth test and add an audit log call │ ┃ jr outage │ ┃ ● Claude status page unreachable ⏺ Read(src/auth.ts) │ ┃ ● GitHub status page unreachable ⎿ Read 6 lines │ ┃ ● npm status page unreachable ⏺ Update(src/auth.ts) │ ┃ ● Vercel status page unreachable ⎿ Added 2 lines, removed 1 line │ ┃ checked 08:53 · every 10 min ⏺ 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 │ │ › /jroutage │ ⎿ jr-outage: jr outage · band hidden (/jroutage to bring it back) │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr-outage
jr outage ● Claude status page unreachable ● GitHub status page unreachable ● npm status page unreachable ● Vercel status page unreachable checked 08:53 · every 10 min
README

jr-outage

Is it them or is it me? jr-outage checks the public status pages of the services a build depends on, every 10 minutes.

▲ GitHub Partial outage of Actions · /jroutage details

One row on the left, only while there is something to act on.

  • Claude, GitHub, npm and Vercel.
  • A band and a toast appear only while one of them reports an incident; an unreachable status page is not treated as an outage.

Commands

Command
/jroutageHide or show the band (it toggles); /jroutage hide and show set it
/jroutage detailsCheck now and show every service in a pane

Settings (/config)

OptionDefault
Show the band during an incidenton

Install

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

Access

Fetches the public status.json of status.claude.com, githubstatus.com, status.npmjs.org and vercel-status.com. No key, no personal data. Remembers whether the band is hidden; runs nothing.

Source 6 files
hooks/register.tsx 146 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { gradientText } from './jr/draw'
5import { GRADIENTS } from './jr/gradient'
6import { COLOR, degraded, serviceOf, SERVICES } from './services'
7
8const view = atom({ plugin: 'jr-outage', key: 'view' } as const, null)
9const bandHidden = atom({ plugin: 'jr-outage', key: 'isBandHidden' } as const, false)
10
11// The band can be hidden: /jroutage hide, show, or the bare command to toggle. One choice for
12// every session, kept across restarts.
13const band = { isLoaded: false }
14
15const bandVerb = (args: string) => {
16  const arg = args.trim().toLowerCase()
17  if (arg === 'hide' || arg === 'off') return 'hide' as const
18  if (arg === 'show' || arg === 'on') return 'show' as const
19  return arg === '' ? ('toggle' as const) : null
20}
21
22async function isBandHidden($: EngineInterface) {
23  if (!band.isLoaded) {
24    band.isLoaded = true
25    if ((await $.store.get('isBandHidden')) === true) await update($, bandHidden, () => true)
26  }
27  return read($, bandHidden)
28}
29
30async function setBand($: EngineInterface, verb: 'hide' | 'show' | 'toggle') {
31  const hide = verb === 'toggle' ? !(await isBandHidden($)) : verb === 'hide'
32  await $.store.set('isBandHidden', hide)
33  await update($, bandHidden, () => hide)
34  return { text: hide ? 'jr outage · band hidden (/jroutage to bring it back)' : 'jr outage · band shown' }
35}
36const PANE = 'jr-outage'
37const EVERY_MS = 10 * 60_000
38
39const live = { isPolling: false }
40
41async function fetchStatus($: EngineInterface, url: string) {
42  try {
43    const res = await $.http.fetch(url, { headers: { Accept: 'application/json' } })
44    return res.ok ? res.text : null
45  } catch {
46    return null
47  }
48}
49
50async function check($: EngineInterface) {
51  const bodies = await Promise.all(SERVICES.map(s => fetchStatus($, s.url)))
52  const services = SERVICES.map((s, i) => serviceOf(s.name, bodies[i] ?? null))
53  const before = await read($, view)
54  const checkedAt = await $.clock.now()
55  await update($, view, () => ({ services, checkedAt }))
56  for (const s of degraded(services)) {
57    const was = before?.services.find(x => x.name === s.name)
58    if (!was || was.indicator !== s.indicator) $.ui.toast(`jr outage · ${s.name}: ${s.description}`)
59  }
60}
61
62async function ensurePolling($: EngineInterface) {
63  if (live.isPolling) return
64  live.isPolling = true
65  await check($)
66  $.clock.every(EVERY_MS, () => {
67    void check($)
68  })
69}
70
71export const register: Register = (on, options) => {
72  const showBand = options.showBand !== false
73
74  on('session.start', async ($, e, next) => {
75    const started = await next(e)
76    await $.command.register({ name: 'jroutage', description: 'Show or hide the outage band; /jroutage details checks Claude, GitHub, npm and Vercel now' })
77    await ensurePolling($)
78    return started
79  })
80
81  on('command.run', { command: 'jroutage' }, async ($, e) => {
82    const verb = bandVerb(e.args)
83    if (verb) return setBand($, verb)
84    live.isPolling ? await check($) : await ensurePolling($)
85    await $.ui.open({ id: PANE, title: 'jr outage', rows: SERVICES.length + 3 })
86    const down = degraded((await read($, view))?.services ?? [])
87    return { text: down.length ? `jr outage · ${down.map(s => `${s.name}: ${s.description}`).join(' · ')}` : 'jr outage · all services operational.' }
88  })
89
90  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
91    const { Box, Text } = $.ui.resolve(e)
92    const v = await read($, view)
93    if (!v) return <Text dimColor>Checking status pages…</Text>
94    return (
95      <Box flexDirection="column">
96        {gradientText(Text, 'jr outage', GRADIENTS.brand)}
97        {v.services.map(s => (
98          <Box>
99            <Text color={COLOR[s.indicator] ?? COLOR.unknown}>{'● '}</Text>
100            <Text bold>{s.name.padEnd(8)}</Text>
101            <Text color={s.indicator === 'none' ? undefined : (COLOR[s.indicator] ?? COLOR.unknown)} dimColor={s.indicator === 'unknown'}>{s.description}</Text>
102          </Box>
103        ))}
104        <Text dimColor>{`checked ${new Date(v.checkedAt).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} · every 10 min`}</Text>
105      </Box>
106    )
107  })
108
109  // The band appears only while something is degraded.
110  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
111    const below = await next(e)
112    const v = await read($, view)
113    if (!live.isPolling) {
114      $.clock.after(0, () => {
115        void ensurePolling($)
116      })
117    }
118    // Never write state while drawing: the saved choice is loaded just after.
119    if (!band.isLoaded) {
120      $.clock.after(0, () => {
121        void isBandHidden($)
122      })
123    }
124    if (await read($, bandHidden)) return below
125    const down = degraded(v?.services ?? [])
126    if (!showBand || down.length === 0 || e.props.hasSurvey) return below
127    const { Box, Text } = $.ui.resolve(e)
128    return (
129      <Box flexDirection="column">
130        <Text wrap="truncate-end">
131          <Text color={down.some(s => s.indicator === 'major' || s.indicator === 'critical') ? '#ef4565' : '#ff8c42'}>{'▲ '}</Text>
132          {down.map((s, i) => (
133            <Text>
134              {i > 0 ? <Text dimColor>{' · '}</Text> : ''}
135              <Text bold>{`${s.name} `}</Text>
136              <Text color={COLOR[s.indicator]}>{s.description}</Text>
137            </Text>
138          ))}
139          <Text dimColor>{' · /jroutage details'}</Text>
140        </Text>
141        {below}
142      </Box>
143    )
144  })
145}
146
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/services.ts 37 lines
1import type { JrOutageService } from '../types'
2import { printable } from './jr/text'
3
4// Public Statuspage endpoints (no key needed), checked October 2026.
5export const SERVICES = [
6  { name: 'Claude', url: 'https://status.claude.com/api/v2/status.json' },
7  { name: 'GitHub', url: 'https://www.githubstatus.com/api/v2/status.json' },
8  { name: 'npm', url: 'https://status.npmjs.org/api/v2/status.json' },
9  { name: 'Vercel', url: 'https://www.vercel-status.com/api/v2/status.json' },
10] as const
11
12const RANK: Record<string, number> = { none: 0, maintenance: 1, minor: 2, major: 3, critical: 4, unknown: 1 }
13
14export const COLOR: Record<string, string> = {
15  none: '#2cb67d',
16  maintenance: '#3da9fc',
17  minor: '#f4d35e',
18  major: '#ff8c42',
19  critical: '#ef4565',
20  unknown: '#6b7089',
21}
22
23/** A Statuspage status.json body as one service row. */
24export const serviceOf = (name: string, body: string | null): JrOutageService => {
25  try {
26    const status = (JSON.parse(body ?? '') as { status?: { indicator?: string; description?: string } }).status
27    if (status?.indicator) return { name, indicator: printable(status.indicator, 20), description: printable(status.description ?? status.indicator, 120) }
28  } catch {
29    // An unreadable answer is reported as unknown below.
30  }
31  return { name, indicator: 'unknown', description: 'status page unreachable' }
32}
33
34/** Services that are not fully operational, worst first; an unreachable page alone is not an outage. */
35export const degraded = (services: readonly JrOutageService[]) =>
36  services.filter(s => s.indicator !== 'none' && s.indicator !== 'unknown').sort((a, b) => (RANK[b.indicator] ?? 0) - (RANK[a.indicator] ?? 0))
37
hooks/jr/text.ts 13 lines
1// Text from outside (an API, a status page, a cloned repository) is drawn only once it is
2// printable: control characters, escape sequences included, become a visible "�" so nothing
3// can move the cursor, recolour the terminal or hide what follows.
4// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh.
5
6// C0 controls (tab kept), DEL, C1 controls, and bidi overrides that reorder what is shown.
7const UNPRINTABLE = /[\u0000-\u0008\u000a-\u001f\u007f-\u009f‪-‮⁦-⁩]/g
8
9export const printable = (text: unknown, max = 200) => {
10  const s = String(text ?? '').replace(UNPRINTABLE, '�')
11  return s.length > max ? `${s.slice(0, max - 1)}…` : s
12}
13
types/index.d.ts 11 lines
1/** One service as its status page reports it: none, minor, major, critical, maintenance, or unknown. */
2export type JrOutageService = { name: string; indicator: string; description: string }
3
4export type JrOutageView = { services: JrOutageService[]; checkedAt: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'jr-outage': { view: JrOutageView | null; isBandHidden: boolean }
9  }
10}
11