Audible alerts for Claude Code: Claude sounds normal/yellow/red alerts on your server's speakers, with an LCARS status band and alert animations.

Audible alerts for Claude Code. Claude decides when its work deserves your attention and sounds an alert through your server's speakers: a chirp for a milestone, a yellow alert when a big task is done, a red alert klaxon when it's blocked and needs you. A Claude Code mod shows whether the alert system is online and animates every alert in the terminal.
alert tool whose levels and their "when to use" descriptions come from your config, so Claude picks among the levels you define.0), and a console pane and /alert command for sounding alerts by hand.┌───────────────────────── your server ─────────────────────────┐
│ │
│ Claude Code ── red-alert mod ──HTTP──▶ red-alert daemon ──▶ 🔊│
│ (terminal) · alert tool 127.0.0.1:1701 │
│ · status band · levels from TOML │
│ · /alert console · systemd user unit │
│ · desktop notification│
│ red-alert CLI / curl / scripts ──HTTP──▶ │
└───────────────────────────────────────────────────────────────┘
| Level | Sound | Sounds for | Claude uses it when… |
|---|---|---|---|
normal | TNG communicator chirp | once (0.5 s) | a small milestone or FYI: a long build or test run finished, a progress checkpoint |
yellow | computer alert | twice (4.7 s) | a significant body of work is complete and ready for review |
red | TNG red alert klaxon | 12 s (of 21 s) | it is blocked, needs a decision, credentials or approval, or something failed badly |
pw-play) or PulseAudio (paplay); ffplay, mpv and mpg123 also work.git clone https://github.com/dukechain2333/red-alert.git
cd red-alert
./install.sh
red-alert test # plays every level in turn
install.sh installs, for your user only:
| What | Where |
|---|---|
| daemon and CLI | ~/.local/share/red-alert/, ~/.local/bin/red-alert |
| config (kept if it exists) | ~/.config/red-alert/config.toml |
| systemd user service, started now and at boot | ~/.config/systemd/user/red-alert.service |
| Claude Code mod | ~/.claude/skills/red-alert/ (loads as red-alert@skills-dir) |
The service starts at boot because the installer enables lingering (loginctl enable-linger), which starts your user's services without a login. Sounds are downloaded once, on first start, to ~/.cache/red-alert/.
Options: --no-service (files only), --no-mod (no Claude Code mod), --link-mod (symlink the mod to the checkout while you develop it).
The repository is also a plugin marketplace:
claude plugin marketplace add dukechain2333/red-alert
claude plugin install red-alert@red-alert
You still need the daemon: ./install.sh --no-mod.
Start a new Claude Code session after installing. Then:
mcp__red-alert__alert tool and a short system-prompt note: Claude calls it once, as the last step of a turn where one of the levels fits, or right before it stops to ask you something, whether or not you seem to be at the keyboard. To change how often it alerts, tell it ("only red alerts today", "no alerts for this task") or edit the level descriptions. The tool never asks for permission, since all it does is play a sound on your own machine.● ONLINE, ○ OFFLINE or ◐ MUTED 25M), your levels and the last alert. The levels are a menu: press ctrl+x, release, then Tab to step into the band (the cursor lands on the first level), move with ←/→, and press Enter to sound that alert by hand, for the level's duration. Esc takes you back to the prompt. The levels get no number keys on purpose: a bare digit typed into an empty prompt presses the band's buttons, so you couldn't start a message with "1." without sounding an alert. (ctrl+x tab is Claude Code's abovePrompt:focus action; rebind it in ~/.claude/keybindings.json if you like.)When an alert sounds, the band turns into an animated banner that runs for as long as the sound plays, with a countdown when the level has a duration:
Alerts raised elsewhere (another session, the CLI, a script) animate too, with their source shown, so every open session sees them.
0 to silence an alert. While a banner is up, typing 0 into the empty prompt stops the sound and takes the banner down; you don't need to focus anything first. (A bare digit at an empty prompt goes to the band's buttons, as with Claude Code's own surveys; 0 in a message you are typing is just a 0.) After the sound ends, a red or yellow banner from this session stays lit until you press 0 (now Dismiss). Typing your next prompt also clears an alert that Claude raised, and silences it if it's still sounding./alert <level> [duration] [message]: /alert red 30s Meeting in 5 minutes sounds the red alert for 30 seconds (2m works too), /alert yellow sounds yellow for its configured time. /alert runs even while Claude is working./alert with no arguments) has the system status, a Manual alert form and the alert log. Type an optional message, press Enter, then the level's number (1–9) to sound it. Other keys: 0 silence, m mute 30 min, u unmute, r refresh, x close./alert subcommands: status, stop, mute [minutes] (0 = until unmuted), unmute, start (starts the systemd service).▐ LCARS 1701 ▌ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ▐ ALERT CONDITION ▌
SYSTEM ● ONLINE http://127.0.0.1:1701 · v0.2.0 · bridge · up 3h · auto (pw-play)
CONDITION GREEN · STANDING BY
▐ MANUAL ALERT ▌ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Message Lunch is ready_
1: Sound ▐ NORMAL ▌ p10 sweep once A light ping: a small milestone or an FYI…
2: Sound ▐ YELLOW ▌ p50 pulse 4.7s A significant body of work is complete…
3: Sound ▐ RED ▌ p90 klaxon 12s The user is needed now: you are blocked…
▐ LOG ▌ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21:04:11 RED stopped Need the prod DB password · claude-code:shop
20:51:37 YELLOW played Checkout refactor done, 214 tests pass · claude-code:shop
20:12:02 NORMAL played Nightly build finished · cli@bridge
[ Silence ] [ Mute 30m ] [ Unmute ] [ Refresh ] [ Close ]
Set these in /config (or claude plugin configure red-alert):
| Setting | Default | |
|---|---|---|
url | http://127.0.0.1:1701 | where the daemon listens |
token | empty | the daemon's server.token, if you set one |
pollSeconds | 5 | how often the band checks the daemon and picks up alerts raised elsewhere |
permissionPromptLevel | off | sound this level whenever Claude Code waits on a permission prompt, the one wait Claude cannot announce itself (e.g. red) |
idleBand | true | show the status strip while no alert is up |
Edit ~/.config/red-alert/config.toml, then run red-alert reload. Claude Code picks up the change on its own: the mod rebuilds the alert tool from the daemon's levels.
[[levels]]
name = "deploy" # what Claude passes as the level
priority = 40 # higher wins when alerts overlap
sound = "~/sounds/fanfare.ogg" # https:// URL or a file path
style = "pulse" # sweep | pulse | klaxon
color = "#33CC99"
volume = 70 # 0-100
duration = 8 # sound for 8 s: cut a longer sound, loop a
# shorter one; 0 plays it once (the default)
cooldown_seconds = 30 # ignore repeats within 30 s
notify = true # also show a desktop notification
description = "A deployment or release finished successfully."
duration is how long the alert sounds, up to 300 seconds. A 21-second klaxon with duration = 12 stops after 12 seconds; a 2-second doorbell with duration = 6 rings three times. Leave it out (or set 0) to play the sound once, in full. An alert raised by hand or through the API can override it.
Claude reads each description to decide which level to use, so write it as advice on when to use the level. Overlapping alerts: a higher-priority alert cuts off a lower one that is still playing, and a lower one is skipped while a higher one plays. See config.example.toml for every option, including audio.player (choose a player or give your own command line) and server.token.
red-alert send LEVEL [MESSAGE...] sound an alert (-d SECONDS, --title, --source, --json)
red-alert status is the daemon up? what is playing?
red-alert levels the configured levels
red-alert history [-n 20] recent alerts
red-alert test [LEVEL] play one level, or all of them in turn
red-alert stop [ID] silence the alert that is playing
red-alert mute [MINUTES] mute (default 30; 0 = until unmuted)
red-alert unmute
red-alert reload re-read the config
red-alert serve run the daemon in the foreground
The CLI finds the daemon through the config file, $RED_ALERT_URL or --url (and $RED_ALERT_TOKEN or --token). It exits with 3 when the daemon is unreachable.
curl -s localhost:1701/health
curl -s -X POST localhost:1701/alert -H 'Content-Type: application/json' \
-d '{"level": "yellow", "message": "Backup finished", "source": "cron"}'
Endpoints: GET /health, GET /levels, GET /history, POST /alert, POST /stop, POST /mute, POST /unmute, POST /reload. See docs/API.md.
Any Claude Code session that can run shell commands can use the CLI. Put this in your CLAUDE.md:
## Alerts
This machine runs red-alert. When you finish a significant task, or right
before you stop to ask me something, run exactly one of:
- `red-alert send normal "<what finished>"`: small milestone or FYI
- `red-alert send yellow "<summary>"`: a big task is done and ready for review
- `red-alert send red "<what you need>"`: you are blocked and need me now
The simplest setup is an SSH tunnel, which keeps the daemon on localhost:
ssh -N -L 1701:127.0.0.1:1701 you@your-server
To expose the daemon on the network instead, set server.host = "0.0.0.0" and a server.token in the config, restart the service, and set the mod's url and token to match.
red-alert status says offline. Run systemctl --user status red-alert and journalctl --user -u red-alert -e.played. The player reached the sound server; check the default output and its volume with wpctl status (or pactl info). Try pw-play some.wav yourself.failed. red-alert history shows the reason. Usually a sound could not be downloaded (red-alert levels flags uncached sounds) or no player could decode it: install ffmpeg or set audio.player.audio.player at a player that writes to ALSA directly.red-alert/<version>), which works. If it changes, download the files yourself and point sound at them../uninstall.sh # keeps ~/.config/red-alert
./uninstall.sh --purge # also removes the config and cached sounds
python3 -m unittest discover -s tests # daemon tests (a fake player, no sound)
claude plugin validate plugin # the mod's manifest and hooks
claude plugin test plugin # the mod's tests (mocked daemon and clock)
To type-check the mod, run /plugin-types plugin/.claude/types in Claude Code, then npx -p typescript tsc -p plugin.
Layout: red_alert.py (daemon and CLI), config.example.toml, systemd/, install.sh, plugin/ (the Claude Code mod: hooks/register.tsx hooks and UI, hooks/frames.ts animation frames, hooks/api.ts API types, types/index.d.ts its state contract), tests/.
The default sounds are fetched from TrekCore when the daemon first starts. They are not part of this repository. Star Trek and its sounds belong to their respective owners; swap in your own sounds if you need to. The UI borrows the look of LCARS, the Star Trek computer interface.
hooks/register.tsx 1058 lines1// red-alert: the Claude Code side of the alert system.
2//
3// - registers the `alert` tool the model calls on its own judgment, its
4// levels and their descriptions read from the daemon (GET /levels);
5// - adds a short section to the system prompt saying when to use it;
6// - draws an LCARS strip above the prompt that shows whether the daemon is
7// online, and animates every alert (klaxon, pulse or sweep) there for as
8// long as it sounds; `0` at an empty prompt silences it;
9// - opens an alert console pane with /alert, where the person sounds alerts
10// by hand, and answers /alert subcommands (`/alert red 30s message`).
11
12import { atom, read, update } from 'claude-code'
13import type { Elements, EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
14
15import type { ActiveAlert, AlertLevel, AlertOrigin, AlertRecord, DaemonLink } from '../types'
16import { Offline, Refused, endpoint, errorText, parseReply, requestInit } from './api'
17import type { DaemonAlert, DaemonLevel, Health } from './api'
18import { DURATION_MS, FPS, LCARS, bannerColors, barCells, barRuns, chevrons } from './frames'
19
20const TOOL_NAME = 'alert'
21const TOOL = 'mcp__red-alert__alert'
22const PANE = 'alert-console'
23const HISTORY_SIZE = 20
24const MAX_DURATION_S = 300
25/** A daemon call that takes longer counts as the daemon being offline. */
26const CALL_TIMEOUT_MS = 5000
27/** Silences an alert: a bare digit typed into an empty prompt presses the band's Button for it. */
28const SILENCE_KEY = '0'
29const START_COMMAND = ['systemctl', '--user', 'start', 'red-alert']
30const SUBCOMMANDS = '[<level> [30s] [message] | status | stop | mute [minutes] | unmute | start]'
31/** Alert statuses worth a banner: the alert reached the speaker, or would have. */
32const SHOWN = new Set(['playing', 'played', 'muted', 'stopped', 'preempted'])
33
34const link = atom({ plugin: 'red-alert', key: 'link' } as const, null)
35const levels = atom({ plugin: 'red-alert', key: 'levels' } as const, [])
36const history = atom({ plugin: 'red-alert', key: 'history' } as const, [])
37const active = atom({ plugin: 'red-alert', key: 'active' } as const, null)
38const draft = atom({ plugin: 'red-alert', key: 'draft' } as const, '')
39
40/** The tool's levels until the daemon has answered once. */
41const FALLBACK_LEVELS: AlertLevel[] = [
42 {
43 name: 'normal', priority: 10, style: 'sweep', color: '#99CCFF', duration: 0, soundReady: false,
44 description: 'A light ping: a small milestone or FYI, such as a long build or test run that finished.',
45 },
46 {
47 name: 'yellow', priority: 50, style: 'pulse', color: '#FFCC33', duration: 0, soundReady: false,
48 description: 'A significant body of work is complete and ready for review.',
49 },
50 {
51 name: 'red', priority: 90, style: 'klaxon', color: '#FF3333', duration: 12, soundReady: false,
52 description: 'The user is needed now: you are blocked, need a decision, credentials or approval, or something failed badly.',
53 },
54]
55
56const POLICY = `# Audible alerts
57The user runs red-alert: the \`${TOOL}\` tool plays a sound through this machine's speakers and flashes a banner in Claude Code, so the user can step away while you work. Decide on your own when to use it and at which level, following the levels in the tool's description: once, as your last action before you end a turn where a level fits, or right before you stop to wait for the user. Alert whether or not the user seems to be at the keyboard.`
58
59type Table = Elements[keyof Elements]
60
61type Settings = {
62 url: string
63 token: string
64 pollMs: number
65 permissionPromptLevel: string
66 isIdleBandShown: boolean
67}
68
69// The module's own variables: they start over when the module reloads, while
70// everything drawn from lives in $.state.
71let settings: Settings = settingsFrom({})
72let source = 'claude-code'
73let toolHash = ''
74let isPolling = false
75let isSeeded = false
76let pollTimer: Timer | undefined
77let frameTimer: Timer | undefined
78/** Once an animation's planned time is over: when the daemon was last asked, and its answer. */
79let soundCheckedAt = 0
80let isStillSounding = true
81const ownIds = new Set<string>()
82
83function settingsFrom(options: PluginOptions): Settings {
84 const text = (key: string, fallback: string) => {
85 const value = options[key]
86 return typeof value === 'string' && value.trim() ? value.trim() : fallback
87 }
88 const seconds = Number(options.pollSeconds)
89 return {
90 url: text('url', 'http://127.0.0.1:1701'),
91 token: text('token', ''),
92 pollMs: Math.round((Number.isFinite(seconds) && seconds >= 1 ? seconds : 5) * 1000),
93 permissionPromptLevel: text('permissionPromptLevel', 'off').toLowerCase(),
94 isIdleBandShown: options.idleBand !== false,
95 }
96}
97
98// ---------------------------------------------------------------------------
99// Conversions and text
100// ---------------------------------------------------------------------------
101
102function toLevel(level: DaemonLevel): AlertLevel {
103 return {
104 name: level.name,
105 priority: level.priority,
106 description: level.description,
107 color: level.color,
108 style: level.style,
109 duration: level.duration ?? 0,
110 soundReady: level.sound_ready,
111 }
112}
113
114function toRecord(alert: DaemonAlert): AlertRecord {
115 return {
116 id: alert.id,
117 level: alert.level,
118 color: alert.color,
119 style: alert.style,
120 title: alert.title,
121 message: alert.message,
122 source: alert.source,
123 status: alert.status,
124 time: alert.time,
125 duration: alert.duration ?? 0,
126 }
127}
128
129function emptyLink(url: string): DaemonLink {
130 return {
131 online: false,
132 url,
133 checkedAt: 0,
134 error: null,
135 version: null,
136 hostname: null,
137 player: null,
138 uptimeS: null,
139 mute: null,
140 playing: null,
141 levelsHash: null,
142 }
143}
144
145function toolDescription(list: readonly AlertLevel[]): string {
146 return [
147 "Sound an audible alert on the user's machine: its speakers play the level's sound and Claude Code flashes an alert banner, so the user notices even when away from the screen. You decide when to call it and which level fits.",
148 '',
149 'Levels, lowest to highest priority:',
150 ...list.map(level => `- ${level.name}: ${level.description}`),
151 '',
152 'How to use it:',
153 '- Call it at most once per turn, as the last thing you do before ending your turn, or right before you ask a question that blocks you. Pick the highest level that fits.',
154 '- Alert whenever a level fits, whether or not the user seems to be at the keyboard. Never alert for each small step of a task.',
155 '- If the user asks for fewer or no alerts, follow that for the rest of the session.',
156 '- The result says whether the sound played. If the system is offline or muted, carry on; do not retry.',
157 ].join('\n')
158}
159
160function ago(seconds: number): string {
161 const s = Math.max(0, Math.floor(seconds))
162 if (s >= 86400) return `${Math.floor(s / 86400)}D`
163 if (s >= 3600) return `${Math.floor(s / 3600)}H`
164 if (s >= 60) return `${Math.floor(s / 60)}M`
165 return `${s}S`
166}
167
168function clockText(epochSeconds: number): string {
169 const date = new Date(epochSeconds * 1000)
170 return [date.getHours(), date.getMinutes(), date.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
171}
172
173function lengthText(duration: number): string {
174 return duration > 0 ? `${Number(duration.toFixed(1))}s` : 'once'
175}
176
177/** Time left, rounded up: a fresh 30-minute mute reads 30M, not 29M. */
178function left(seconds: number): string {
179 const s = Math.max(0, Math.ceil(seconds))
180 if (s < 60) return `${s}S`
181 const m = Math.ceil(s / 60)
182 return m < 60 ? `${m}M` : `${Math.floor(m / 60)}H${String(m % 60).padStart(2, '0')}M`
183}
184
185function muteLabel(muted: DaemonLink['mute'], nowMs: number): string {
186 if (!muted) return ''
187 return muted.until === null ? 'MUTED' : `MUTED ${left(muted.until - nowMs / 1000)}`
188}
189
190/** `30s`, `2m`, `1.5m` as seconds; undefined for anything else. */
191function parseDuration(word: string | undefined): number | undefined {
192 const match = /^(\d+(?:\.\d+)?)(s|m)$/i.exec(word ?? '')
193 if (!match) return undefined
194 const seconds = Number(match[1]) * (match[2]?.toLowerCase() === 'm' ? 60 : 1)
195 return Math.min(MAX_DURATION_S, seconds)
196}
197
198function outcome(alert: DaemonAlert, host: string): string {
199 const name = alert.level.toUpperCase()
200 switch (alert.status) {
201 case 'playing':
202 case 'played':
203 return `Sounded: ${name} alert is playing on ${host}. The user has been alerted; finish your turn (or ask your question) now.`
204 case 'muted':
205 return `Muted: the user has muted alerts, so the ${name} banner was shown without sound. Carry on.`
206 case 'cooldown':
207 return `Skipped: a ${name} alert sounded moments ago (cooldown). Do not retry.`
208 case 'suppressed':
209 return `Skipped: ${alert.detail ?? 'a higher-priority alert is playing'}. Do not retry.`
210 default:
211 return `Failed: the daemon could not play the sound (${alert.detail ?? alert.status}). Mention this to the user.`
212 }
213}
214
215function manualOutcome(alert: DaemonAlert): string {
216 const what = `${alert.level.toUpperCase()} alert`
217 switch (alert.status) {
218 case 'playing':
219 case 'played':
220 return `${what} sounding (${lengthText(alert.duration ?? 0)}). Press ${SILENCE_KEY} at an empty prompt to silence it.`
221 case 'muted':
222 return `${what} shown, but alerts are muted (/alert unmute).`
223 case 'cooldown':
224 return `${what} skipped: it sounded moments ago (cooldown).`
225 case 'suppressed':
226 return `${what} skipped: ${alert.detail ?? 'a higher-priority alert is playing'}.`
227 default:
228 return `${what} failed: ${alert.detail ?? alert.status}.`
229 }
230}
231
232function statusText(
233 current: DaemonLink | null,
234 list: readonly AlertLevel[],
235 log: readonly AlertRecord[],
236 now: number,
237): string {
238 if (!current?.online) {
239 return `red-alert ○ offline at ${current?.url ?? settings.url}${current?.error ? ` (${current.error})` : ''}. Start it with /alert start.`
240 }
241 const last = log[0]
242 return [
243 `red-alert ● online at ${current.url} (v${current.version} on ${current.hostname}, player ${current.player})`,
244 `levels: ${list.map(level => `${level.name} (p${level.priority}, ${lengthText(level.duration)})`).join(', ')}`,
245 `muted: ${current.mute ? muteLabel(current.mute, now).toLowerCase() : 'no'}`,
246 last
247 ? `last: ${last.level} "${last.message || '-'}" ${last.status}, ${ago(now / 1000 - last.time).toLowerCase()} ago`
248 : 'last: none',
249 ].join('\n')
250}
251
252// ---------------------------------------------------------------------------
253// The daemon
254// ---------------------------------------------------------------------------
255
256async function call<T>($: EngineInterface, method: 'GET' | 'POST', path: string, body?: object): Promise<T> {
257 // A daemon that takes the connection but never answers must not hang a poll or a tool call.
258 const wait = new AbortController()
259 const timedOut = $.clock.sleep(CALL_TIMEOUT_MS, { signal: wait.signal }).then(() => {
260 throw new Offline(`no answer in ${CALL_TIMEOUT_MS / 1000} s`)
261 })
262 let response: Awaited<ReturnType<EngineInterface['http']['fetch']>>
263 try {
264 response = await Promise.race([
265 $.http.fetch(endpoint(settings.url, path), requestInit(method, body, settings.token)),
266 timedOut,
267 ])
268 } catch (error) {
269 throw error instanceof Offline ? error : new Offline(errorText(error))
270 } finally {
271 wait.abort()
272 }
273 return parseReply<T>(response, settings.url)
274}
275
276async function registerTool($: EngineInterface, list: readonly AlertLevel[]): Promise<void> {
277 const ordered = [...(list.length > 0 ? list : FALLBACK_LEVELS)].sort((a, b) => a.priority - b.priority)
278 await $.tool.register({
279 name: TOOL_NAME,
280 description: toolDescription(ordered),
281 inputSchema: {
282 type: 'object',
283 properties: {
284 level: {
285 type: 'string',
286 enum: ordered.map(level => level.name),
287 description: 'Which alert to sound; see the levels above.',
288 },
289 message: {
290 type: 'string',
291 maxLength: 200,
292 description:
293 'One short line, in the language you are using with the user, saying what happened or what you need from them.',
294 },
295 },
296 required: ['level', 'message'],
297 additionalProperties: false,
298 },
299 })
300}
301
302async function markOffline($: EngineInterface, checkedAt: number, reason: string): Promise<void> {
303 await update($, link, previous => ({
304 ...(previous ?? emptyLink(settings.url)),
305 online: false,
306 url: settings.url,
307 checkedAt,
308 error: reason,
309 playing: null,
310 }))
311 $.ui.status('○ alert system offline · /alert start')
312}
313
314/** Checks the daemon, refreshes levels and history, and shows alerts raised elsewhere. */
315async function poll($: EngineInterface): Promise<void> {
316 if (isPolling) return
317 isPolling = true
318 try {
319 const checkedAt = await $.clock.now()
320 let health: Health
321 try {
322 health = await call<Health>($, 'GET', '/health')
323 } catch (error) {
324 await markOffline($, checkedAt, errorText(error))
325 return
326 }
327 await update($, link, () => ({
328 online: true,
329 url: settings.url,
330 checkedAt,
331 error: null,
332 version: health.version,
333 hostname: health.hostname,
334 player: health.player,
335 uptimeS: health.uptime_s,
336 mute: health.mute,
337 playing: health.playing,
338 levelsHash: health.levels_hash,
339 }))
340 $.ui.status(health.mute ? `◐ alerts ${muteLabel(health.mute, checkedAt).toLowerCase()}` : undefined)
341
342 if (health.levels_hash !== toolHash) {
343 const list = (await call<{ levels: DaemonLevel[] }>($, 'GET', '/levels')).levels.map(toLevel)
344 await update($, levels, () => list)
345 await registerTool($, list)
346 toolHash = health.levels_hash
347 }
348
349 const known = new Set((await read($, history)).map(alert => alert.id))
350 let alerts: DaemonAlert[]
351 try {
352 alerts = (await call<{ alerts: DaemonAlert[] }>($, 'GET', `/history?limit=${HISTORY_SIZE}`)).alerts
353 } catch {
354 alerts = health.last_alert ? [health.last_alert] : []
355 }
356 if (alerts.length > 0) {
357 await update($, history, () => alerts.map(toRecord))
358 }
359
360 // Silenced from elsewhere (another session, the CLI): take the banner down too.
361 const shown = await read($, active)
362 if (shown && alerts.some(alert => alert.id === shown.id && alert.status === 'stopped')) {
363 await clearBanner($, shown.id)
364 }
365
366 const fresh = isSeeded
367 ? alerts.find(alert => !known.has(alert.id) && !ownIds.has(alert.id) && SHOWN.has(alert.status))
368 : undefined
369 isSeeded = true
370 if (fresh) {
371 const from = fresh.source ? ` from ${fresh.source}` : ''
372 $.ui.toast(`${fresh.title}${from}: ${fresh.message || fresh.level}`, { timeoutMs: 6000 })
373 await showAlert($, fresh, 'remote')
374 }
375 } catch (error) {
376 $.ui.log(`red-alert: health check failed: ${errorText(error)}`, { to: 'debug' })
377 } finally {
378 isPolling = false
379 }
380}
381
382async function refresh($: EngineInterface): Promise<void> {
383 await poll($)
384}
385
386// ---------------------------------------------------------------------------
387// Alerts and their animation
388// ---------------------------------------------------------------------------
389
390async function raise(
391 $: EngineInterface,
392 alert: { level: string; message: string; from: string; origin: AlertOrigin; duration?: number },
393): Promise<DaemonAlert> {
394 const body = {
395 level: alert.level,
396 message: alert.message,
397 source: alert.from,
398 ...(alert.duration === undefined ? {} : { duration: alert.duration }),
399 }
400 const { alert: raised } = await call<{ alert: DaemonAlert }>($, 'POST', '/alert', body)
401 ownIds.add(raised.id)
402 await update($, history, list => [toRecord(raised), ...list.filter(one => one.id !== raised.id)].slice(0, HISTORY_SIZE))
403 if (SHOWN.has(raised.status)) {
404 await showAlert($, raised, alert.origin)
405 }
406 return raised
407}
408
409/** Sounds a level by hand: from /alert, or a console button. */
410async function soundByHand($: EngineInterface, level: string, message: string, duration?: number): Promise<DaemonAlert> {
411 return raise($, {
412 level,
413 message: message || `Manual ${level} alert`,
414 from: `${source} (manual)`,
415 origin: 'manual',
416 duration,
417 })
418}
419
420/** A console Sound button: sounds `level` with the message typed into the console. */
421async function soundDraft($: EngineInterface, level: string): Promise<void> {
422 const message = (await read($, draft)).trim()
423 try {
424 const alert = await soundByHand($, level, message)
425 await update($, draft, () => '')
426 if (!SHOWN.has(alert.status)) $.ui.toast(manualOutcome(alert))
427 } catch (error) {
428 $.ui.toast(error instanceof Offline ? 'The alert system is offline.' : `red-alert: ${errorText(error)}`)
429 }
430}
431
432async function showAlert($: EngineInterface, alert: DaemonAlert, origin: AlertOrigin): Promise<void> {
433 const now = await $.clock.now()
434 const isPlaying = alert.status === 'playing'
435 const duration = isPlaying ? (alert.duration ?? 0) : 0
436 const shown: ActiveAlert = {
437 id: alert.id,
438 level: alert.level,
439 color: alert.color,
440 style: alert.style,
441 title: alert.title,
442 message: alert.message,
443 source: alert.source,
444 origin,
445 duration,
446 startedAt: now,
447 animateUntil: now + (duration > 0 ? Math.max(2000, duration * 1000) : DURATION_MS[alert.style]),
448 isLatched: origin !== 'remote' && alert.style !== 'sweep',
449 }
450 soundCheckedAt = 0
451 isStillSounding = isPlaying
452 await update($, active, () => shown)
453 animate($)
454}
455
456function animate($: EngineInterface): void {
457 frameTimer?.cancel()
458 frameTimer = $.clock.every(Math.round(1000 / FPS), () => void nextFrame($))
459}
460
461/**
462 * Whether the daemon still plays `id`, asked at most once a second: a sound
463 * played once runs as long as its file, which the mod does not know.
464 */
465async function isSounding($: EngineInterface, id: string, now: number): Promise<boolean> {
466 if (now - soundCheckedAt >= 1000) {
467 soundCheckedAt = now
468 try {
469 isStillSounding = (await call<Health>($, 'GET', '/health')).playing?.id === id
470 } catch {
471 isStillSounding = false
472 }
473 }
474 return isStillSounding
475}
476
477/** One animation frame: redraw, or settle the band once the animation and the sound are over. */
478async function nextFrame($: EngineInterface): Promise<void> {
479 const timer = frameTimer
480 const [now, current] = await Promise.all([$.clock.now(), read($, active)])
481 if (current && (now < current.animateUntil || (await isSounding($, current.id, now)))) {
482 $.ui.invalidate('ui.render')
483 return
484 }
485 // Another alert took the band while the daemon answered: its own frames settle it.
486 if (frameTimer !== timer) return
487 frameTimer?.cancel()
488 frameTimer = undefined
489 if (current && !current.isLatched) {
490 await update($, active, shown => (shown?.id === current.id ? null : shown))
491 }
492 $.ui.invalidate('ui.render')
493}
494
495async function clearBanner($: EngineInterface, id: string): Promise<void> {
496 frameTimer?.cancel()
497 frameTimer = undefined
498 await update($, active, shown => (shown?.id === id ? null : shown))
499}
500
501/**
502 * Takes the banner down and stops its sound; with `isEverything`, stops
503 * whatever the daemon is playing, banner or not.
504 */
505async function silence($: EngineInterface, isEverything = false): Promise<string | null> {
506 const current = await read($, active)
507 if (current) {
508 await clearBanner($, current.id)
509 }
510 const body = current && !isEverything ? { id: current.id } : {}
511 return (await call<{ stopped: string | null }>($, 'POST', '/stop', body)).stopped
512}
513
514async function silenceQuietly($: EngineInterface, isEverything = false): Promise<void> {
515 await silence($, isEverything).catch(() => null)
516}
517
518async function mute($: EngineInterface, minutes: number): Promise<void> {
519 await call($, 'POST', '/mute', { minutes })
520 await poll($)
521}
522
523async function unmute($: EngineInterface): Promise<void> {
524 await call($, 'POST', '/unmute', {})
525 await poll($)
526}
527
528async function startDaemon($: EngineInterface): Promise<string> {
529 try {
530 const run = await $.process.run(START_COMMAND, { timeoutMs: 15000 })
531 if (run.exitCode !== 0) {
532 return `Could not start it: ${run.stderr.trim() || `exit ${run.exitCode}`}`
533 }
534 } catch (error) {
535 return `Could not start it: ${errorText(error)}`
536 }
537 await $.clock.sleep(800)
538 await poll($)
539 return (await read($, link))?.online
540 ? 'Alert system online.'
541 : 'Started, but it is not answering yet: see `journalctl --user -u red-alert`.'
542}
543
544async function startAndToast($: EngineInterface): Promise<void> {
545 $.ui.toast(await startDaemon($))
546}
547
548/** A level item on the band: sounds that alert by hand, for the level's own duration. */
549async function soundFromBand($: EngineInterface, level: string): Promise<void> {
550 try {
551 const alert = await soundByHand($, level, '')
552 if (!SHOWN.has(alert.status)) $.ui.toast(manualOutcome(alert))
553 } catch (error) {
554 $.ui.toast(error instanceof Offline ? 'The alert system is offline.' : `red-alert: ${errorText(error)}`)
555 }
556}
557
558async function openConsole($: EngineInterface, isFocused: boolean): Promise<boolean> {
559 const opened = await $.ui.open({ id: PANE, title: 'Alert console', ...(isFocused ? { focus: true } : {}) })
560 return opened.isPlaced
561}
562
563/** After the message field's Enter: the ring moves to the levels, where a digit sounds one. */
564async function focusFirstSound($: EngineInterface): Promise<void> {
565 const [first] = [...(await read($, levels))].sort((a, b) => a.priority - b.priority)
566 if (first) {
567 await $.ui.focus({ requestId: PANE, key: `sound:${first.name}` }).catch(() => null)
568 }
569}
570
571// ---------------------------------------------------------------------------
572// Drawing
573// ---------------------------------------------------------------------------
574
575function pill(t: Table, label: string, color: string) {
576 const { Text } = t
577 return [
578 <Text color={color}>▐</Text>,
579 <Text backgroundColor={color} color={LCARS.ink} bold>{` ${label} `}</Text>,
580 <Text color={color}>▌</Text>,
581 ]
582}
583
584function rule(t: Table, width: number, color: string) {
585 const { Text } = t
586 return <Text color={color}>{'━'.repeat(Math.max(1, width))}</Text>
587}
588
589function statusPill(current: DaemonLink | null, now: number): { label: string; color: string } {
590 if (!current || current.checkedAt === 0) return { label: '◌ LINKING', color: LCARS.tan }
591 if (!current.online) return { label: '○ OFFLINE', color: LCARS.red }
592 if (current.mute) return { label: `◐ ${muteLabel(current.mute, now)}`, color: LCARS.peach }
593 return { label: '● ONLINE', color: LCARS.green }
594}
595
596/**
597 * The strip shown while no alert is up: the daemon's state, then the levels
598 * as Buttons the person reaches with the band's focus (ctrl+x tab), walks
599 * with ←/→ and presses with Enter to sound that alert by hand. No digit
600 * hotkeys: a bare digit at an empty prompt would press them while the
601 * person starts a message.
602 */
603function idleStrip(
604 $: EngineInterface,
605 t: Table,
606 width: number,
607 current: DaemonLink | null,
608 list: readonly AlertLevel[],
609 log: readonly AlertRecord[],
610 now: number,
611) {
612 const { Box, Text, Button } = t
613 const status = statusPill(current, now)
614
615 // the status and the levels first; then the brand, the rule and the last alert where room is left
616 const levelsWidth = list.reduce((n, level) => n + level.name.length + 2, 0) + 2 * Math.max(0, list.length - 1)
617 const fixed = 1 + 1 + status.label.length + 4 + (list.length > 0 ? 2 + levelsWidth : 0)
618 const brand = width - fixed - 18 >= 4 ? '◉ ALERT SYSTEM' : '◉'
619 let used = brand.length + 4 + fixed
620 const last = log[0]
621 const lastText = last ? `LAST ${last.level.toUpperCase()} ${ago(now / 1000 - last.time)} AGO` : ''
622 const isLastShown = lastText !== '' && width - used - 4 >= lastText.length + 2
623 if (isLastShown) used += lastText.length + 2
624
625 return (
626 <Box flexDirection="row">
627 {pill(t, brand, LCARS.orange)}
628 <Text> </Text>
629 {rule(t, width - used, LCARS.lavender)}
630 <Text> </Text>
631 {pill(t, status.label, status.color)}
632 {list.length > 0 && <Text> </Text>}
633 {list.map((level, i) => [
634 i > 0 ? <Text> </Text> : null,
635 <Text color={level.color} bold>● </Text>,
636 <Button
637 key={`band:level:${level.name}`}
638 label={level.name.toUpperCase()}
639 plain
640 {...(i === 0 ? { autoFocus: true } : {})}
641 onPress={() => void soundFromBand($, level.name)}
642 />,
643 ])}
644 {isLastShown && <Text color={LCARS.tan}>{` ${lastText}`}</Text>}
645 </Box>
646 )
647}
648
649/** The alert banner, with animated light bars above and below while it sounds. */
650function alertBanner($: EngineInterface, t: Table, width: number, maxRows: number, shown: ActiveAlert, now: number) {
651 const { Box, Text, Button } = t
652 const ms = now - shown.startedAt
653 const isAnimating = frameTimer !== undefined || now < shown.animateUntil
654 const colors = bannerColors(shown.style, shown.color, ms, !isAnimating)
655 const Raster = 'Raster' in t ? t.Raster : undefined
656
657 const wanted = shown.style === 'klaxon' ? 2 : 1
658 const spare = Math.max(0, maxRows - 1)
659 const top = isAnimating ? Math.min(wanted, shown.style === 'sweep' ? spare : Math.floor(spare / 2)) : 0
660 const bottom = isAnimating && shown.style !== 'sweep' ? Math.min(wanted, spare - top) : 0
661
662 const bars = (rows: number, edge: 'top' | 'bottom') => {
663 if (rows <= 0) return null
664 if (Raster) {
665 return (
666 <Raster
667 key={`bars:${edge}`}
668 columns={width}
669 rows={rows}
670 cells={barCells(shown.style, shown.color, width, rows, ms, edge)}
671 />
672 )
673 }
674 const runs = barRuns(shown.style, shown.color, Math.max(8, Math.floor(width / 4)), ms)
675 const run = Math.floor(width / runs.length)
676 return (
677 <Box flexDirection="row">
678 {runs.map((color, i) => (
679 <Text color={color}>{'█'.repeat(i === runs.length - 1 ? width - run * (runs.length - 1) : run)}</Text>
680 ))}
681 </Box>
682 )
683 }
684
685 const title =
686 shown.style === 'klaxon' && isAnimating
687 ? ` ${chevrons(ms, 'left')} ${shown.title} ${chevrons(ms, 'right')} `
688 : shown.style === 'sweep'
689 ? ` ◉ INCOMING · ${shown.title} `
690 : ` ◆ ${shown.title} ◆ `
691 const from =
692 shown.origin === 'remote' ? ` · ${shown.source || 'elsewhere'}` : shown.origin === 'manual' ? ' · manual' : ''
693 const secondsLeft = shown.duration > 0 ? Math.ceil((shown.startedAt + shown.duration * 1000 - now) / 1000) : 0
694
695 return (
696 <Box flexDirection="column">
697 {bars(top, 'top')}
698 <Box flexDirection="row" width={width} backgroundColor={colors.background}>
699 <Text backgroundColor={colors.background} color={colors.foreground} bold>{title}</Text>
700 <Box flexGrow={1} flexShrink={1}>
701 <Text backgroundColor={colors.background} color={colors.foreground} wrap="truncate">
702 {` ${shown.message}${from} `}
703 </Text>
704 </Box>
705 {secondsLeft > 0 && (
706 <Text backgroundColor={colors.background} color={colors.foreground}>{` ${secondsLeft}s `}</Text>
707 )}
708 <Button
709 key="band:silence"
710 label={isAnimating ? 'Silence' : 'Dismiss'}
711 hotkey={SILENCE_KEY}
712 plain
713 onPress={() => void silenceQuietly($)}
714 />
715 <Text backgroundColor={colors.background}> </Text>
716 </Box>
717 {bars(bottom, 'bottom')}
718 </Box>
719 )
720}
721
722function consoleHeader(t: Table, width: number) {
723 const { Box, Text } = t
724 return (
725 <Box flexDirection="row">
726 {pill(t, 'LCARS 1701', LCARS.orange)}
727 <Text> </Text>
728 {rule(t, width - 34, LCARS.lavender)}
729 <Text> </Text>
730 {pill(t, 'ALERT CONDITION', LCARS.violet)}
731 </Box>
732 )
733}
734
735function section(t: Table, width: number, label: string, color: string) {
736 const { Box, Text } = t
737 return (
738 <Box flexDirection="row" marginTop={1}>
739 {pill(t, label, color)}
740 <Text> </Text>
741 {rule(t, width - label.length - 5, color)}
742 </Box>
743 )
744}
745
746// ---------------------------------------------------------------------------
747// Hooks
748// ---------------------------------------------------------------------------
749
750export const register: Register = (on, options) => {
751 settings = settingsFrom(options)
752
753 on('session.start', async ($, e, next) => {
754 source = `claude-code:${e.cwd.split('/').filter(Boolean).pop() ?? 'session'}`
755 await $.command.register({
756 name: 'alert',
757 description: 'Alert console; sound an alert by hand, silence, mute, status',
758 argumentHint: SUBCOMMANDS,
759 immediate: true,
760 })
761 await registerTool($, await read($, levels))
762 await Promise.race([poll($), $.clock.sleep(1500)])
763 pollTimer?.cancel()
764 pollTimer = $.clock.every(settings.pollMs, () => void refresh($))
765 if (await read($, active)) {
766 animate($)
767 }
768 return next(e)
769 })
770
771 // A /clear or /resume ends the conversation, not the process, and no
772 // session.start follows it: keep the band polling and any banner animating.
773 on('session.end', ($, e, next) => {
774 if (e.reason === 'clear' || e.reason === 'resume') {
775 return next(e)
776 }
777 pollTimer?.cancel()
778 frameTimer?.cancel()
779 return next(e)
780 })
781
782 // The person is back at the keyboard: Claude's alert has done its job.
783 on('prompt.submit', async ($, e, next) => {
784 const current = await read($, active)
785 if (current?.origin === 'claude') {
786 await Promise.race([silenceQuietly($), $.clock.sleep(400)])
787 }
788 return next(e)
789 })
790
791 // -- the tool --------------------------------------------------------------
792
793 on('tool.describe', { tool: TOOL }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
794
795 // Playing a sound on the user's own machine needs no permission prompt.
796 on('tool.check', { tool: TOOL }, () => ({
797 decision: 'allow',
798 reason: 'red-alert: sounding an alert on this machine is always allowed',
799 }))
800
801 on('tool.call', { tool: TOOL }, async ($, e) => {
802 const level = typeof e.level === 'string' ? e.level : ''
803 const message = typeof e.message === 'string' ? e.message : ''
804 try {
805 const alert = await raise($, { level, message, from: source, origin: 'claude' })
806 const host = (await read($, link))?.hostname ?? 'this machine'
807 return { result: outcome(alert, host) }
808 } catch (error) {
809 if (error instanceof Offline) {
810 void refresh($)
811 return {
812 result: `Offline: the alert system at ${settings.url} is not answering, so no sound played. Carry on without it and do not retry.`,
813 }
814 }
815 if (error instanceof Refused) {
816 const valid = error.levels.length > 0 ? ` Valid levels: ${error.levels.join(', ')}.` : ''
817 return { result: `Refused: ${error.message}.${valid}` }
818 }
819 throw error
820 }
821 })
822
823 on('prompt.compose', async ($, e, next) => {
824 const composed = await next(e)
825 if (!e.tools.includes(TOOL)) return composed
826 return { sections: [...composed.sections, { id: 'red-alert:policy', text: POLICY, scope: 'session' }] }
827 })
828
829 // Optional: a permission prompt is the one wait the model cannot announce.
830 on('classic.Notification', async ($, e, next) => {
831 const level = settings.permissionPromptLevel
832 if (level !== 'off' && e.notification_type === 'permission_prompt') {
833 await raise($, { level, message: e.message, from: `${source} (permission prompt)`, origin: 'claude' }).catch(
834 () => null,
835 )
836 }
837 return next(e)
838 })
839
840 // -- /alert ----------------------------------------------------------------
841
842 on('command.run', { command: 'alert' }, async ($, e) => {
843 const words = e.args.trim().split(/\s+/).filter(Boolean)
844 const verb = (words[0] ?? '').toLowerCase()
845 try {
846 switch (verb) {
847 case '':
848 case 'console':
849 return {
850 text: (await openConsole($, false)) ? 'Alert console opened.' : 'Alert console: widen the terminal to see it.',
851 }
852 case 'status': {
853 await poll($)
854 const [current, list, log, now] = await Promise.all([
855 read($, link),
856 read($, levels),
857 read($, history),
858 $.clock.now(),
859 ])
860 return { text: statusText(current, list, log, now) }
861 }
862 case 'stop':
863 case 'silence': {
864 const stopped = await silence($, true)
865 return { text: stopped ? 'Alert silenced.' : 'Nothing was playing.' }
866 }
867 case 'mute': {
868 const minutes = words[1] === undefined ? 30 : Number(words[1])
869 if (!Number.isFinite(minutes) || minutes < 0) {
870 return { text: 'Usage: /alert mute [minutes] (0 = until /alert unmute)' }
871 }
872 await mute($, minutes)
873 return { text: minutes === 0 ? 'Alerts muted until /alert unmute.' : `Alerts muted for ${minutes} min.` }
874 }
875 case 'unmute': {
876 await unmute($)
877 return { text: 'Alerts unmuted.' }
878 }
879 case 'start':
880 return { text: await startDaemon($) }
881 default: {
882 // `/alert <level> [30s] [message]`; also `/alert sound <level> ...` and `/alert test [level]`
883 const rest = verb === 'sound' || verb === 'test' ? words.slice(1) : words
884 const list = await read($, levels)
885 const level = (rest[0] ?? (verb === 'test' ? list[list.length - 1]?.name : undefined))?.toLowerCase()
886 if (!level || (list.length > 0 && !list.some(one => one.name === level))) {
887 const names = list.map(one => one.name).join(', ')
888 return { text: `Usage: /alert ${SUBCOMMANDS}${names ? `\nlevels: ${names}` : ''}` }
889 }
890 const duration = parseDuration(rest[1])
891 const message = rest.slice(duration === undefined ? 1 : 2).join(' ')
892 return { text: manualOutcome(await soundByHand($, level, message, duration)) }
893 }
894 }
895 } catch (error) {
896 if (error instanceof Offline) {
897 return { text: `The alert system at ${settings.url} is offline. Start it with /alert start.` }
898 }
899 if (error instanceof Refused) {
900 const valid = error.levels.length > 0 ? ` (levels: ${error.levels.join(', ')})` : ''
901 return { text: `red-alert: ${error.message}${valid}` }
902 }
903 throw error
904 }
905 })
906
907 // -- drawing ---------------------------------------------------------------
908
909 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
910 if (e.props.hasSurvey) return next(e)
911 const [current, shown, list, log, now] = await Promise.all([
912 read($, link),
913 read($, active),
914 read($, levels),
915 read($, history),
916 $.clock.now(),
917 ])
918 const t = $.ui.resolve(e)
919 // the engine draws its own collapse mark, ` [-]`, at the band's right edge
920 const width = Math.max(20, e.props.bodyColumns - 4)
921 if (shown) {
922 return alertBanner($, t, width, Math.max(1, e.props.maxRows - 1), shown, now)
923 }
924 if (!settings.isIdleBandShown) return next(e)
925 return idleStrip($, t, width, current, list, log, now)
926 })
927
928 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
929 const [current, shown, list, log, message, now] = await Promise.all([
930 read($, link),
931 read($, active),
932 read($, levels),
933 read($, history),
934 read($, draft),
935 $.clock.now(),
936 ])
937 const t = $.ui.resolve(e)
938 const { Box, Text, Button } = t
939 const Input = 'Input' in t ? t.Input : undefined
940 const width = Math.max(30, e.props.bodyColumns)
941 const rows = e.viewport?.rows ?? 30
942 const ordered = [...list].sort((a, b) => a.priority - b.priority)
943
944 const condition = shown
945 ? { label: `${shown.level.toUpperCase()} ALERT`, color: shown.color }
946 : current?.online
947 ? { label: 'GREEN · STANDING BY', color: LCARS.green }
948 : { label: 'NO CONTACT', color: LCARS.red }
949 const system = current?.online
950 ? `● ONLINE ${current.url} · v${current.version} · ${current.hostname} · up ${ago(current.uptimeS ?? 0).toLowerCase()} · ${current.player}`
951 : `○ OFFLINE ${current?.url ?? settings.url}${current?.error ? ` · ${current.error}` : ''}`
952 const logRoom = Math.max(1, rows - ordered.length - 15)
953
954 return (
955 <Box flexDirection="column">
956 {shown ? alertBanner($, t, width, 5, shown, now) : consoleHeader(t, width)}
957 <Box flexDirection="row" marginTop={1}>
958 <Text color={LCARS.sand} bold>{'SYSTEM '}</Text>
959 <Text color={current?.online ? LCARS.green : LCARS.red} wrap="truncate">{system}</Text>
960 </Box>
961 <Box flexDirection="row">
962 <Text color={LCARS.sand} bold>{'CONDITION '}</Text>
963 <Text color={condition.color} bold>{condition.label}</Text>
964 {current?.mute && <Text color={LCARS.peach}>{` · ${muteLabel(current.mute, now)}`}</Text>}
965 </Box>
966
967 {section(t, width, 'MANUAL ALERT', LCARS.violet)}
968 {Input && (
969 <Input
970 key="manual:message"
971 label="Message "
972 placeholder="optional; Enter, then a level's number"
973 value={message}
974 submitLabel="pick a level"
975 onInput={value => void update($, draft, () => value)}
976 onSubmit={value => void update($, draft, () => value).then(() => focusFirstSound($))}
977 />
978 )}
979 {ordered.length === 0 && <Text dimColor>No levels yet: the daemon has not answered.</Text>}
980 {ordered.map((level, i) => (
981 <Box flexDirection="row">
982 <Button
983 key={`sound:${level.name}`}
984 label="Sound"
985 {...(i < 9 ? { hotkey: String(i + 1) } : {})}
986 plain
987 dimColor
988 onPress={() => void soundDraft($, level.name)}
989 />
990 <Text> </Text>
991 {pill(t, level.name.toUpperCase().padEnd(8), level.color)}
992 <Text color={LCARS.tan}>
993 {` p${String(level.priority).padEnd(4)}${level.style.padEnd(7)}${lengthText(level.duration).padEnd(6)}`}
994 </Text>
995 <Box flexShrink={1}>
996 <Text dimColor wrap="truncate">
997 {level.soundReady ? level.description : `(sound not cached yet) ${level.description}`}
998 </Text>
999 </Box>
1000 </Box>
1001 ))}
1002
1003 {section(t, width, 'LOG', LCARS.peach)}
1004 {log.length === 0 && <Text dimColor>No alerts yet.</Text>}
1005 {log.slice(0, logRoom).map(alert => (
1006 <Box flexDirection="row">
1007 <Text dimColor>{`${clockText(alert.time)} `}</Text>
1008 <Text color={alert.color} bold>{alert.level.toUpperCase().padEnd(8)}</Text>
1009 <Text color={LCARS.tan}>{alert.status.padEnd(11)}</Text>
1010 <Box flexShrink={1}>
1011 <Text wrap="truncate">{`${alert.message || '-'}${alert.source ? ` · ${alert.source}` : ''}`}</Text>
1012 </Box>
1013 </Box>
1014 ))}
1015
1016 <Box flexDirection="row" marginTop={1} gap={1}>
1017 <Button key="silence" label="Silence" hotkey={SILENCE_KEY} onPress={() => void silenceQuietly($, true)} />
1018 <Button key="mute" label="Mute 30m" hotkey="m" onPress={() => void mute($, 30).catch(() => null)} />
1019 <Button key="unmute" label="Unmute" hotkey="u" onPress={() => void unmute($).catch(() => null)} />
1020 {current !== null && current.checkedAt > 0 && !current.online && (
1021 <Button key="start" label="Start daemon" hotkey="d" variant="primary" onPress={() => void startAndToast($)} />
1022 )}
1023 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void refresh($)} />
1024 <Button key="close" label="Close" hotkey="x" role="dismiss" onPress={() => void $.ui.close({ id: PANE })} />
1025 </Box>
1026 </Box>
1027 )
1028 })
1029
1030 // The tool's own transcript row: a pill in the level's color.
1031 on('ui.render', { component: 'ToolUse', props: { tool: TOOL } }, async ($, e) => {
1032 const input = (e.props.input ?? {}) as { level?: unknown; message?: unknown }
1033 const level = typeof input.level === 'string' ? input.level : '?'
1034 const message = typeof input.message === 'string' ? input.message : ''
1035 const color = (await read($, levels)).find(one => one.name === level)?.color ?? LCARS.orange
1036 const output = typeof e.props.output === 'string' ? e.props.output : ''
1037 const state = e.props.isRunning
1038 ? 'sounding…'
1039 : e.props.isInterrupted
1040 ? 'interrupted'
1041 : e.props.isErrored
1042 ? 'failed'
1043 : (output.split(':')[0] ?? '').toLowerCase()
1044 const t = $.ui.resolve(e)
1045 const { Box, Text } = t
1046 return (
1047 <Box flexDirection="row">
1048 {pill(t, `${level.toUpperCase()} ALERT`, color)}
1049 <Text> </Text>
1050 <Box flexShrink={1}>
1051 <Text wrap="truncate">{message}</Text>
1052 </Box>
1053 <Text dimColor>{` ${state}`}</Text>
1054 </Box>
1055 )
1056 })
1057}
1058hooks/api.ts 90 lines1// The daemon's HTTP API (docs/API.md): shapes, request building and reply
2// parsing. The fetch itself is `$.http.fetch` in register.tsx.
3
4import type { AlertStyle } from '../types'
5
6/** An alert as the daemon reports it. */
7export type DaemonAlert = {
8 id: string
9 level: string
10 priority: number
11 color: string
12 style: AlertStyle
13 title: string
14 message: string
15 source: string
16 time: number
17 duration: number
18 status: string
19 detail: string | null
20}
21
22export type Health = {
23 ok: true
24 version: string
25 hostname: string
26 time: number
27 uptime_s: number
28 player: string
29 levels: string[]
30 levels_hash: string
31 playing: { id: string; level: string } | null
32 mute: { until: number | null } | null
33 last_alert: DaemonAlert | null
34}
35
36export type DaemonLevel = {
37 name: string
38 priority: number
39 description: string
40 color: string
41 style: AlertStyle
42 duration: number
43 sound_ready: boolean
44}
45
46/** The daemon could not be reached at all. */
47export class Offline extends Error {}
48
49/** The daemon answered with an error. */
50export class Refused extends Error {
51 readonly levels: readonly string[]
52
53 constructor(message: string, levels: readonly string[] = []) {
54 super(message)
55 this.levels = levels
56 }
57}
58
59export function errorText(error: unknown): string {
60 return error instanceof Error ? error.message : String(error)
61}
62
63export function endpoint(base: string, path: string): string {
64 return base.replace(/\/+$/, '') + path
65}
66
67export function requestInit(method: 'GET' | 'POST', body: object | undefined, token: string) {
68 const headers: Record<string, string> = { Accept: 'application/json' }
69 if (body !== undefined) {
70 headers['Content-Type'] = 'application/json'
71 }
72 if (token) {
73 headers.Authorization = `Bearer ${token}`
74 }
75 return { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }
76}
77
78export function parseReply<T>(response: { ok: boolean; status: number; text: string }, base: string): T {
79 let payload: { error?: string; levels?: string[] }
80 try {
81 payload = JSON.parse(response.text)
82 } catch {
83 throw new Refused(`HTTP ${response.status} from ${base}: not the red-alert daemon?`)
84 }
85 if (!response.ok) {
86 throw new Refused(payload.error ?? `HTTP ${response.status}`, payload.levels ?? [])
87 }
88 return payload as T
89}
90hooks/frames.ts 167 lines1// Animation frames for the alert band: pure functions of (style, color, time).
2//
3// The light bars above and below an alert banner are a `Raster` on the
4// terminal (one cell per column, packed as RasterProps says) and a handful
5// of colored `Text` runs on surfaces without one. Text never goes into a
6// Raster: messages may hold wide characters, which a Raster cell cannot.
7
8import type { AlertStyle } from '../types'
9
10/** LCARS console colors. */
11export const LCARS = {
12 orange: '#FF9900',
13 sand: '#FFCC99',
14 peach: '#FF9966',
15 lavender: '#CC99CC',
16 violet: '#9999FF',
17 blue: '#99CCFF',
18 tan: '#CC9966',
19 green: '#66DD99',
20 red: '#FF5555',
21 ink: '#000000',
22} as const
23
24/** How long each style animates before the band settles, in milliseconds. */
25export const DURATION_MS: Record<AlertStyle, number> = { sweep: 2600, pulse: 6000, klaxon: 10000 }
26
27/** Frames per second while an alert animates. */
28export const FPS = 20
29
30/** A klaxon banner is lit for this long, then dark for as long. */
31const BLINK_MS = 420
32
33type Rgb = readonly [number, number, number]
34
35function rgb(color: string): Rgb {
36 const n = Number.parseInt(color.slice(1, 7), 16)
37 return Number.isNaN(n) ? [255, 153, 0] : [(n >> 16) & 255, (n >> 8) & 255, n & 255]
38}
39
40function channel(v: number): string {
41 return Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')
42}
43
44/** Blends `a` toward `b` by `t` (0..1). */
45export function mix(a: string, b: string, t: number): string {
46 const [ar, ag, ab] = rgb(a)
47 const [br, bg, bb] = rgb(b)
48 const k = Math.max(0, Math.min(1, t))
49 return `#${channel(ar + (br - ar) * k)}${channel(ag + (bg - ag) * k)}${channel(ab + (bb - ab) * k)}`.toUpperCase()
50}
51
52/**
53 * Brightness (0..1) of the bar cell at `x` (0 at the left edge, 1 at the
54 * right) `ms` into the animation. `row` 0 is nearest the banner.
55 */
56function intensity(style: AlertStyle, x: number, ms: number, row: number): number {
57 const s = ms / 1000
58 if (style === 'klaxon') {
59 // Waves of light running outward from the center, as on a starship's
60 // red alert panel, under a throb that peaks while the banner is lit.
61 const d = Math.abs(x - 0.5) * 2
62 const wave = 0.5 + 0.5 * Math.cos(2 * Math.PI * (d * 2.4 - s * 1.7 + row * 0.18))
63 const throb = 0.6 + 0.4 * Math.cos((2 * Math.PI * (ms - BLINK_MS / 2)) / (BLINK_MS * 2))
64 return wave * wave * throb
65 }
66 if (style === 'pulse') {
67 // A slow breath, brightest at the center.
68 const breath = 0.5 + 0.5 * Math.sin(2 * Math.PI * (s / 1.4) - Math.PI / 2)
69 return breath * (1 - 0.45 * Math.abs(x - 0.5) * 2)
70 }
71 // sweep: a scanner head running left to right with a fading tail.
72 const head = ((s / 1.25) % 1) * 1.3 - 0.15
73 const behind = head - x
74 return behind >= 0 && behind < 0.18 ? 1 - behind / 0.18 : 0
75}
76
77function barColor(style: AlertStyle, color: string, x: number, ms: number, row: number): string {
78 const floor = style === 'sweep' ? 0.12 : 0.1
79 return mix(mix(LCARS.ink, color, floor), color, intensity(style, x, ms, row))
80}
81
82/** Every SEGMENT-th column is a gap, so the bars read as LCARS segments. */
83const SEGMENT = 7
84const DEFAULT_COLOR = 0x01000000
85
86function packed(color: string): number {
87 const [r, g, b] = rgb(color)
88 return (r << 16) | (g << 8) | b
89}
90
91function base64(bytes: Uint8Array): string {
92 const native = bytes as Uint8Array & { toBase64?: () => string }
93 if (typeof native.toBase64 === 'function') {
94 return native.toBase64()
95 }
96 let binary = ''
97 for (let i = 0; i < bytes.length; i += 0x8000) {
98 binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
99 }
100 return btoa(binary)
101}
102
103/**
104 * Raster cells for `rows` rows of light bars, `columns` wide. `edge` says
105 * which side of the banner they sit on: the row farthest from the banner is
106 * a thin half-block line, the rest full blocks.
107 */
108export function barCells(
109 style: AlertStyle,
110 color: string,
111 columns: number,
112 rows: number,
113 ms: number,
114 edge: 'top' | 'bottom',
115): string {
116 const view = new DataView(new ArrayBuffer(columns * rows * 12))
117 for (let y = 0; y < rows; y += 1) {
118 const fromBanner = edge === 'top' ? rows - 1 - y : y
119 const isOuter = fromBanner === rows - 1 && rows > 1
120 const glyph = isOuter ? (edge === 'top' ? 0x2584 : 0x2580) : rows === 1 ? (edge === 'top' ? 0x2584 : 0x2580) : 0x2588
121 for (let x = 0; x < columns; x += 1) {
122 const offset = (y * columns + x) * 12
123 const isGap = style !== 'sweep' && x % SEGMENT === SEGMENT - 1
124 view.setUint32(offset, isGap ? 0x20 : glyph, true)
125 view.setUint32(offset + 4, isGap ? DEFAULT_COLOR : packed(barColor(style, color, x / Math.max(1, columns - 1), ms, fromBanner)), true)
126 view.setUint32(offset + 8, DEFAULT_COLOR, true)
127 }
128 }
129 return base64(new Uint8Array(view.buffer))
130}
131
132/** The same bars as `count` colored runs, for surfaces without a Raster. */
133export function barRuns(style: AlertStyle, color: string, count: number, ms: number): string[] {
134 return Array.from({ length: count }, (_, i) => barColor(style, color, i / Math.max(1, count - 1), ms, 0))
135}
136
137/** Banner colors `ms` into the animation; a settled (latched) banner is steady. */
138export function bannerColors(
139 style: AlertStyle,
140 color: string,
141 ms: number,
142 isSettled: boolean,
143): { background: string; foreground: string } {
144 if (isSettled) {
145 return { background: mix(LCARS.ink, color, 0.85), foreground: LCARS.ink }
146 }
147 if (style === 'klaxon') {
148 const isOn = Math.floor(ms / BLINK_MS) % 2 === 0
149 return isOn
150 ? { background: color, foreground: LCARS.ink }
151 : { background: mix(LCARS.ink, color, 0.18), foreground: color }
152 }
153 if (style === 'pulse') {
154 const breath = 0.5 + 0.5 * Math.sin(2 * Math.PI * (ms / 1400) - Math.PI / 2)
155 return { background: mix(LCARS.ink, color, 0.35 + 0.65 * breath), foreground: LCARS.ink }
156 }
157 return { background: mix(LCARS.ink, color, 0.22), foreground: color }
158}
159
160/** Chevrons that march toward the title while a klaxon sounds. */
161export function chevrons(ms: number, side: 'left' | 'right'): string {
162 const step = Math.floor(ms / 140) % 3
163 return [0, 1, 2]
164 .map(i => (side === 'left' ? (i === step ? '▶' : '▷') : i === 2 - step ? '◀' : '◁'))
165 .join('')
166}
167types/index.d.ts 84 lines1/** How an alert animates in Claude Code; set per level in the daemon config. */
2export type AlertStyle = 'sweep' | 'pulse' | 'klaxon'
3
4/** One alert level, as the daemon's GET /levels describes it. */
5export type AlertLevel = {
6 name: string
7 priority: number
8 description: string
9 color: string
10 style: AlertStyle
11 /** How long it sounds, in seconds: cut or looped to fit; 0 plays the sound once. */
12 duration: number
13 soundReady: boolean
14}
15
16/** One alert, as the daemon's history records it. */
17export type AlertRecord = {
18 id: string
19 level: string
20 color: string
21 style: AlertStyle
22 title: string
23 message: string
24 source: string
25 status: string
26 /** Seconds since the epoch, the daemon's clock. */
27 time: number
28 /** Seconds it sounds for; 0 plays the sound once. */
29 duration: number
30}
31
32/** What the last health check of the daemon found. */
33export type DaemonLink = {
34 online: boolean
35 url: string
36 /** Milliseconds since the epoch of the last check; 0 before the first. */
37 checkedAt: number
38 error: string | null
39 version: string | null
40 hostname: string | null
41 player: string | null
42 uptimeS: number | null
43 /** Muted: `until` in epoch seconds, or null for "until unmuted". */
44 mute: { until: number | null } | null
45 playing: { id: string; level: string } | null
46 levelsHash: string | null
47}
48
49/** Who raised an alert: this session's Claude, the person by hand, or anyone else. */
50export type AlertOrigin = 'claude' | 'manual' | 'remote'
51
52/** The alert the band is showing: animating while it sounds, then latched until silenced. */
53export type ActiveAlert = {
54 id: string
55 level: string
56 color: string
57 style: AlertStyle
58 title: string
59 message: string
60 source: string
61 origin: AlertOrigin
62 /** Seconds the sound runs for; 0 when it plays once (its length unknown). */
63 duration: number
64 /** Milliseconds since the epoch. */
65 startedAt: number
66 /** The animation runs at least until then, and on while the sound plays. */
67 animateUntil: number
68 /** Keeps the band lit after the animation until the person silences it. */
69 isLatched: boolean
70}
71
72declare module 'claude-code' {
73 interface PluginState {
74 'red-alert': {
75 link: DaemonLink | null
76 levels: AlertLevel[]
77 history: AlertRecord[]
78 active: ActiveAlert | null
79 /** The message typed into the console's manual-alert field. */
80 draft: string
81 }
82 }
83}
84