SLOPSHOPPER

red-alert

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

newpanebandrowsguardcommand
★ 1v0.2.0MITupdated 2026-10-03dukechain2333/red-alert/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · red-alert
│ ┃ Alert console ✕ › fix the failing auth test and add an audit log call │ ┃ ▐ LCARS 1701 ▌ ━━━━━━━━━━━━━━━━━━━━━━ ▐ ALER │ ┃ ⏺ Read(src/auth.ts) │ ┃ SYSTEM ○ OFFLINE http://127.0.0.1:1701 ⎿ Read 6 lines │ ┃ CONDITION NO CONTACT ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ▐ MANUAL ALERT ▌ ━━━━━━━━━━━━━━━━━━━━━━━━━━━ ⏺ Bash(bun test) │ ┃ Message : optional; Enter, then a level's nu ⎿ 3 pass, 1 fail │ ┃ No levels yet: the daemon has not answered. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▐ LOG ▌ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ ┃ ━━━━ ✻ Worked for 42s · done 4:20 PM │ ┃ No alerts yet. │ ┃ › /alert │ ┃ [ Silence ] [ Mute 30m ] [ Unmute ] [ Start ⎿ red-alert: Alert console opened. │ │ ▐ ◉ ALERT SYSTEM ▌ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ▐ ○ OFFLINE ▌ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ red-alert: ○ alert system offline · /alert start

Draws

Band
▐ ◉ ALERT SYSTEM ▌ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ▐ ○ OFFLINE ▌
Pane · Alert console
▐ LCARS 1701 ▌ ━━━━━━━━━━━━━━━━━━━━━━ ▐ ALERT CONDITION ▌ SYSTEM ○ OFFLINE http://127.0.0.1:1701 · HTTP 0 from ht CONDITION NO CONTACT ▐ MANUAL ALERT ▌ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Message : optional; Enter, then a level's number ⏎ pick a le No levels yet: the daemon has not answered. ▐ LOG ▌ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ No alerts yet. [ Silence ] [ Mute 30m ] [ Unmute ] [ Start daemon ] [ Refre
README

red-alert

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.

The red-alert band in Claude Code: idle, red alert, yellow alert and normal alert

  • Claude chooses the level. The mod gives Claude an alert tool whose levels and their "when to use" descriptions come from your config, so Claude picks among the levels you define.
  • Any number of levels, any sounds. Define levels in one TOML file: name, sound (URL or file), how long it sounds (cut or looped to fit), priority, color, animation, volume and more.
  • Runs in the background. A small Python daemon (standard library only) runs as a systemd user service and starts at boot.
  • LCARS UI in Claude Code. An online/offline status band above the prompt, animated alert banners (klaxon, pulse, sweep) that run for as long as the alert sounds, a key to silence them (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──▶                     │
└───────────────────────────────────────────────────────────────┘

Default alert levels

LevelSoundSounds forClaude uses it when…
normalTNG communicator chirponce (0.5 s)a small milestone or FYI: a long build or test run finished, a progress checkpoint
yellowcomputer alerttwice (4.7 s)a significant body of work is complete and ready for review
redTNG red alert klaxon12 s (of 21 s)it is blocked, needs a decision, credentials or approval, or something failed badly

Requirements

  • Linux with systemd and a sound server: PipeWire (pw-play) or PulseAudio (paplay); ffplay, mpv and mpg123 also work.
  • Python 3.11 or newer (the system Python on Ubuntu 24.04 and Debian 12 is fine).
  • Claude Code with mod support (function-hook plugins).

Install

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:

WhatWhere
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).

Install the mod from GitHub instead

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.

Using it with Claude Code

Start a new Claude Code session after installing. Then:

  • Claude raises alerts by itself. The mod adds the 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.
  • The band above the prompt shows the alert system's state (● 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:

  • klaxon (red): two rows of light bars above and below, with waves running outward from the center, and a banner that flashes with marching chevrons;
  • pulse (yellow): one bar above and below, and the panel breathes;
  • sweep (normal): a scanner line runs across.

Alerts raised elsewhere (another session, the CLI, a script) animate too, with their source shown, so every open session sees them.

  • Press 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.
  • Sound an alert by hand with /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.
  • The alert console (/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.
  • Other /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 ]

Mod settings

Set these in /config (or claude plugin configure red-alert):

SettingDefault
urlhttp://127.0.0.1:1701where the daemon listens
tokenemptythe daemon's server.token, if you set one
pollSeconds5how often the band checks the daemon and picks up alerts raised elsewhere
permissionPromptLeveloffsound this level whenever Claude Code waits on a permission prompt, the one wait Claude cannot announce itself (e.g. red)
idleBandtrueshow the status strip while no alert is up

Customizing levels

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.

CLI

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.

HTTP API

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.

Without the mod

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

Claude Code on another machine

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.

Troubleshooting

  • red-alert status says offline. Run systemctl --user status red-alert and journalctl --user -u red-alert -e.
  • No sound, but the status says 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.
  • Status 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.
  • Silent after a reboot until someone logs in. Lingering starts the service at boot, but some setups only give the sound card to the user logged in at the console. Enable automatic login for the desktop, or point audio.player at a player that writes to ALSA directly.
  • Downloading from trekcore.com fails with 406. The site rejects some user agents; the daemon sends its own (red-alert/<version>), which works. If it changes, download the files yourself and point sound at them.

Uninstall

./uninstall.sh            # keeps ~/.config/red-alert
./uninstall.sh --purge    # also removes the config and cached sounds

Development

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/.

Credits

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.

License

MIT

Source 4 files
hooks/register.tsx 1058 lines
1// 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}
1058
hooks/api.ts 90 lines
1// 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}
90
hooks/frames.ts 167 lines
1// 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}
167
types/index.d.ts 84 lines
1/** 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