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…

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.
| Command | |
|---|---|
/jroutage | Hide or show the band (it toggles); /jroutage hide and show set it |
/jroutage details | Check now and show every service in a pane |
/config)| Option | Default | |
|---|---|---|
| Show the band during an incident | on |
/plugin install jr-outage --marketplace jrlprost/claude-code-mods
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.
hooks/register.tsx 146 lines1import { 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}
146hooks/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/services.ts 37 lines1import 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))
37hooks/jr/text.ts 13 lines1// 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}
13types/index.d.ts 11 lines1/** 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