Your Alarum events above Claude Code's prompt: what just arrived, and one press to hand it to the agent.

Your Alarum events where you work: what arrives while a session runs, without leaving the terminal for the inbox.
A line above the prompt holds the latest event your filters leave, with a count per level, and takes nothing else. A side pane does the work:
Alarum (3), and Alarum at zeroSeveral sessions open at once share one read: whichever finds the window stale fetches it, the others draw what it left.
claude plugin marketplace add FrBmt-BIGetNouf/alarum-claude-mod && claude plugin install alarum@alarum
To move to a newer version, refresh the catalogue and update, then restart Claude Code:
claude plugin marketplace update alarum && claude plugin update alarum@alarum
An .alarum file at the repo root, holding an agent token created under Settings → Your agent in Alarum. The agent API is part of the paid plans.
token=alrm_at_…
api=https://alarum.me/api/v1
Optional lines:
| Line | What it does |
|---|---|
project=<id or slug> | the scope the pane opens on, the first time |
workspace=<id or name> | same, for a whole workspace |
levels=warn,error | pins which levels toast, instead of following each project's notification rules |
poll=30 | seconds between two reads while a turn runs (half that while Claude is idle) |
Add .alarum to your .gitignore: the token reads every project the account can reach.
.alarum itself and refuses to let the agent read it, so the token never enters the conversation.alarum in the repo means no pane and no request at allMIT, see LICENSE.
hooks/register.tsx 949 lines1// Glyphs: ⨳ Alarum; levels ℹ info, ✓ success, ⚠ warn, ✕ error, ⛔ critical;
2// actions ⤴ investigate, ✓ mark read, ⌕ open, ⟳ refresh, ⊙ scope, + - fold.
3//
4// Alarum mod: the account's events, live, in a band above the prompt or in a
5// side pane. It reads them through the agent API (/api/v1, an account token in
6// `.alarum`), which also lets it mark them read, so what you clear in the
7// terminal is cleared in the web inbox.
8//
9// What it is NOT: a second inbox. It shows what arrives while you work, and
10// hands an event to the agent (⤴) when it deserves a look. Anything older than
11// the window belongs in the app.
12//
13// Three things it refuses to do, all for one reason (the token reads every
14// project of the account): the agent never reads `.alarum`, any token in a
15// tool's output is masked, and nothing from that file ever reaches $.state.
16//
17// No `.alarum` in the repo: no band, no request, nothing drawn. The mod is
18// installed once and must not follow you into repositories that do not use it.
19import { atom, read, update } from 'claude-code'
20import type { EngineInterface, Register, RenderNode } from 'claude-code'
21
22import type { AlarumEvent, Level, Project, Scope, Workspace } from '../types'
23import { parseConfig } from './config'
24import type { Config } from './config'
25import { mask, refusal, TOKEN_RE } from './guards'
26import { investigatePrompt } from './investigate'
27import {
28 ago, announce, BOLD, COLOR, firstLine, GLYPH, group, LEVELS, newestAt,
29 inScope, openingScope, scopeLabel, toEvent, visible,
30} from './events'
31import type { Row } from './events'
32
33const PANE = 'alarum'
34const TOAST = { timeoutMs: 8000 }
35
36const SHOWN = 6 // rows the band draws before "+N more"
37const WINDOW = 120 // events asked for: one account-wide window for every session (#695)
38const NARROW = 100 // band columns below which the header labels shrink to a glyph
39const NARROW_PANE = 60
40const TIGHT = 132 // and below which a row's own labels do, a row carrying more of them
41const TIGHT_PANE = 78
42const TICK_MS = 5_000 // how often the clock is consulted, not how often we read
43
44// Alarum's orange, the band's frame and title. Spelled rgb(), as a theme
45// spells its colours: the same value as a hex came out duller in some
46// terminals. On a light background the brand orange washes out against white,
47// so a deeper amber stands in; everything else is left to the theme.
48const ORANGE = 'rgb(245,166,35)'
49const AMBER = 'rgb(176,106,0)'
50let brand = ORANGE
51async function readBrand($: EngineInterface) {
52 const rows = await $.config.list().catch(() => [])
53 const theme = rows.find(row => row.key === 'theme')?.value
54 brand = typeof theme === 'string' && theme.startsWith('light') ? AMBER : ORANGE
55}
56
57const events = atom({ plugin: 'alarum', key: 'events' } as const, [])
58const projects = atom({ plugin: 'alarum', key: 'projects' } as const, [])
59const workspaces = atom({ plugin: 'alarum', key: 'workspaces' } as const, [])
60const scope = atom({ plugin: 'alarum', key: 'scope' } as const, { kind: 'all' })
61const error = atom({ plugin: 'alarum', key: 'error' } as const, null)
62const isBusy = atom({ plugin: 'alarum', key: 'isBusy' } as const, false)
63const isPickingScope = atom({ plugin: 'alarum', key: 'isPickingScope' } as const, false)
64const layout = atom({ plugin: 'alarum', key: 'layout' } as const, 'band')
65const selected = atom({ plugin: 'alarum', key: 'selected' } as const, null)
66const levels = atom({ plugin: 'alarum', key: 'levels' } as const, LEVELS)
67const unreadOnly = atom({ plugin: 'alarum', key: 'unreadOnly' } as const, false)
68const pausedUntil = atom({ plugin: 'alarum', key: 'pausedUntil' } as const, 0)
69const seenUpTo = atom({ plugin: 'alarum', key: 'seenUpTo' } as const, 0)
70const now = atom({ plugin: 'alarum', key: 'now' } as const, 0)
71
72// The repo's config; module-local, never in $.state.
73let config: Config | null = null
74// Where the `.alarum` was found, as the key everything remembered hangs off.
75let repoKey = ''
76// When the list was last read, until when the API asked us to wait (429), and
77// whether the plan refusal has already been said once.
78let lastRefresh = 0
79let quietUntil = 0
80let saidPlan = false
81
82// ── Setup ───────────────────────────────────────────────────────────────────
83
84// Walking up from the session's directory, like a CLI would. Windows paths too
85// (Claude Desktop): `C:\Workspace\repo`, its own separator kept.
86async function findConfig($: EngineInterface): Promise<Config | null> {
87 let dir = await $.session.cwd()
88 const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/'
89 for (let i = 0; i < 30; i++) {
90 const path = `${dir}${sep}.alarum`
91 if (await $.fs.exists(path)) {
92 repoKey = dir.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
93 return parseConfig(await $.fs.read(path))
94 }
95 const up = dir.replace(/[\\/][^\\/]+$/, '')
96 if (!up || up === dir) return null
97 dir = up
98 }
99 return null
100}
101
102// ── What the mod remembers ──────────────────────────────────────────────────
103// $.store is one JSON file per plugin under the user's Claude Code directory,
104// so it outlives the session AND is shared by every session on this machine.
105// That cuts both ways: preferences have to be told apart per repo, and the
106// event window is worth sharing on purpose (#695).
107
108async function remember($: EngineInterface, key: string, value: unknown) {
109 await $.store.set(`${repoKey}:${key}`, value).catch(() => undefined)
110}
111
112async function recall($: EngineInterface, key: string): Promise<unknown> {
113 const own = await $.store.get(`${repoKey}:${key}`).catch(() => undefined)
114 if (own !== undefined && own !== null) return own
115 // Earlier versions kept one value for every repo at once: read it once so a
116 // choice already made is not thrown away.
117 return $.store.get(key).catch(() => undefined)
118}
119
120// ── The shared window (#695) ────────────────────────────────────────────────
121// Four to six sessions, each reading the same events every 20 seconds, is the
122// same answer fetched six times. They now share one window through the store:
123// whoever finds it stale fetches, the others read what it left.
124//
125// Keyed by the ACCOUNT, never by the repo: two repos holding two different
126// tokens must not see each other's events. The key is a hash of the API and
127// the token, so the token itself never reaches the store, and nothing of it
128// can be read back out of a 32-bit digest.
129let fingerprint = ''
130
131function fingerprintOf(c: Config): string {
132 let h = 0x811c9dc5 // FNV-1a, enough to name a bucket, not a signature
133 const material = `${c.api}\n${c.token}`
134 for (let i = 0; i < material.length; i++) {
135 h ^= material.charCodeAt(i)
136 h = Math.imul(h, 0x01000193) >>> 0
137 }
138 return h.toString(36)
139}
140
141async function readShared($: EngineInterface, key: string): Promise<unknown> {
142 return $.store.get(`shared:${fingerprint}:${key}`).catch(() => undefined)
143}
144
145async function writeShared($: EngineInterface, key: string, value: unknown) {
146 await $.store.set(`shared:${fingerprint}:${key}`, value).catch(() => undefined)
147}
148
149/** A shared entry still worth reading, or null. */
150function fresh(entry: unknown, now: number, ttlMs: number): { at: number; value: unknown } | null {
151 if (!entry || typeof entry !== 'object') return null
152 const e = entry as { at?: unknown; value?: unknown }
153 if (typeof e.at !== 'number' || now - e.at >= ttlMs || now < e.at) return null
154 return { at: e.at, value: e.value }
155}
156
157// ── The API ─────────────────────────────────────────────────────────────────
158
159type Answer = { ok: true; json: unknown } | null
160
161/**
162 * One call to the agent API. Every failure becomes something the band can say
163 * in one line: a band that silently stops updating is exactly the failure this
164 * whole mod exists to avoid.
165 */
166async function call($: EngineInterface, path: string, init: { method?: string; body?: unknown } = {}): Promise<Answer> {
167 if (!config) return null
168 let res
169 try {
170 res = await $.http.fetch(`${config.api}${path}`, {
171 method: init.method ?? 'GET',
172 headers: {
173 Authorization: `Bearer ${config.token}`,
174 ...(init.body === undefined ? {} : { 'Content-Type': 'application/json' }),
175 },
176 ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
177 })
178 } catch {
179 await update($, error, () => 'offline, retrying')
180 return null
181 }
182
183 if (res.status === 401) {
184 await update($, error, () => 'token refused: check token= in .alarum')
185 return null
186 }
187 if (res.status === 403) {
188 // The plan lost the agent API (a downgrade, an expired subscription). Said
189 // once: repeating it every 30 seconds would be its own kind of noise.
190 const plan = /plan_required/.test(res.text)
191 await update($, error, () => (plan ? 'the agent API needs a paid plan' : 'no access to that project'))
192 if (plan && !saidPlan) {
193 saidPlan = true
194 $.ui.toast('Alarum: the agent API is part of the paid plans. The band stays empty until the account has one.', TOAST)
195 }
196 return null
197 }
198 if (res.status === 429) {
199 // Wait as told; the band keeps what it already shows.
200 const wait = Number(res.headers['retry-after'] ?? 60)
201 const at = await $.clock.now().catch(() => lastRefresh)
202 quietUntil = at + (Number.isFinite(wait) ? wait : 60) * 1000
203 await update($, pausedUntil, () => quietUntil)
204 return null
205 }
206 if (!res.ok) {
207 await update($, error, () => `error ${res.status}`)
208 return null
209 }
210
211 await update($, pausedUntil, () => 0)
212 try {
213 return { ok: true, json: res.text ? JSON.parse(res.text) : null }
214 } catch {
215 await update($, error, () => 'unreadable response')
216 return null
217 }
218}
219
220const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback)
221const num = (v: unknown) => (typeof v === 'number' ? v : 0)
222
223/** The scope lists: what the picker offers, and the push rules the toasts obey. */
224/**
225 * The scope lists: what the picker offers, and the push rules the toasts obey.
226 * Shared like the window, and kept longer: projects and workspaces change by
227 * the week, and the unread counts they carry are refreshed on every write.
228 */
229const SCOPES_TTL = 300_000
230
231async function loadScopes($: EngineInterface, opts: { force?: boolean } = {}) {
232 const at = await $.clock.now().catch(() => lastRefresh)
233 if (!opts.force) {
234 const hit = fresh(await readShared($, 'scopes'), at, SCOPES_TTL)
235 const held = hit?.value as { projects?: Project[]; workspaces?: Workspace[] } | undefined
236 if (held && Array.isArray(held.projects) && Array.isArray(held.workspaces)) {
237 await update($, projects, () => held.projects as Project[])
238 await update($, workspaces, () => held.workspaces as Workspace[])
239 return
240 }
241 }
242
243 const [ps, ws] = await Promise.all([call($, '/projects'), call($, '/workspaces')])
244 if (ps) {
245 const rows = (ps.json as { projects?: unknown[] })?.projects ?? []
246 const list = rows.map((raw): Project => {
247 const r = raw as Record<string, unknown>
248 const push = (r.push ?? {}) as Record<string, unknown>
249 return {
250 id: str(r.id), workspaceId: str(r.workspaceId), workspaceName: str(r.workspaceName),
251 name: str(r.name), slug: str(r.slug), unreadCount: num(r.unreadCount),
252 push: Object.fromEntries(LEVELS.map(l => [l, push[l] === true])) as Record<Level, boolean>,
253 }
254 })
255 await update($, projects, () => list)
256 }
257 if (ws) {
258 const rows = (ws.json as { workspaces?: unknown[] })?.workspaces ?? []
259 const list = rows.map((raw): Workspace => {
260 const r = raw as Record<string, unknown>
261 return { id: str(r.id), name: str(r.name), unreadCount: num(r.unreadCount) }
262 })
263 await update($, workspaces, () => list)
264 }
265
266 // Only a pair that both arrived is worth leaving for the others.
267 if (ps && ws) {
268 await writeShared($, 'scopes', {
269 at,
270 value: { projects: await read($, projects), workspaces: await read($, workspaces) },
271 })
272 }
273}
274
275/**
276 * Read the window and announce what is new.
277 *
278 * The WHOLE window is read each time, not the delta since the newest event: a
279 * delta costs the same one request against the per-minute cap, and drifts away
280 * from events read, deleted or marked elsewhere. Correctness for the same price.
281 */
282/**
283 * Take a window, announce what is new in THIS session's scope, and hold it.
284 *
285 * The window is account-wide and the scope is applied here, so every session
286 * can read the same one. Announcing is per session all the same: each keeps
287 * its own `seenUpTo`, and a session looking at one project does not toast
288 * another's events.
289 */
290async function hold($: EngineInterface, list: AlarumEvent[]) {
291 if (!config) return
292 const where = await read($, scope)
293 const baseline = await read($, seenUpTo)
294 const news = announce(inScope(list, where), baseline, await read($, projects), config.levels, await read($, levels))
295 for (const e of news) $.ui.toast(`${GLYPH[e.level]} ${e.projectName}: ${e.title}`, TOAST)
296
297 // The baseline moves with the WHOLE window, not the scoped part: what was on
298 // screen under another scope is not news when the scope comes back to it.
299 const newest = newestAt(list)
300 await update($, seenUpTo, prev => (newest > prev ? newest : prev))
301 await update($, events, () => list)
302 await update($, error, () => null)
303 await syncPaneTitle($)
304}
305
306/**
307 * Read the window, from the store when another session has just read it.
308 *
309 * `force` skips the shared copy, for the moments where staleness would be a
310 * lie: a press on Refresh, and right after a write of our own.
311 */
312async function refresh($: EngineInterface, opts: { force?: boolean } = {}) {
313 if (!config) return
314 const at = await $.clock.now().catch(() => lastRefresh)
315 await update($, now, () => at)
316 lastRefresh = at
317 if (at < quietUntil) return
318
319 if (!opts.force) {
320 const hit = fresh(await readShared($, 'events'), at, config.poll * 1000)
321 if (hit && Array.isArray(hit.value)) {
322 await hold($, hit.value as AlarumEvent[])
323 return
324 }
325 }
326
327 // No scope in the query on purpose: one window for every session (#695).
328 const answer = await call($, `/events?limit=${WINDOW}`)
329 if (!answer) return
330
331 const rows = (answer.json as { events?: unknown[] })?.events ?? []
332 const list = rows.map(toEvent).filter((e): e is AlarumEvent => e !== null)
333
334 await writeShared($, 'events', { at, value: list })
335 await hold($, list)
336}
337
338// ── Actions ─────────────────────────────────────────────────────────────────
339
340/**
341 * A write of our own makes every shared copy a lie: the other sessions would
342 * go on drawing an event as unread for up to a poll. Dropping the stamps makes
343 * the next tick of each of them read the API again.
344 */
345async function invalidate($: EngineInterface) {
346 await writeShared($, 'events', null)
347 await writeShared($, 'scopes', null)
348}
349
350async function markRead($: EngineInterface, id: string, next: boolean) {
351 // Drawn at once, then confirmed: a tick that waits for the network feels broken.
352 await update($, events, list => list.map(e => (e.id === id ? { ...e, read: next } : e)))
353 await syncPaneTitle($)
354 const answer = await call($, `/events/${encodeURIComponent(id)}/read`, { method: next ? 'POST' : 'DELETE' })
355 if (!answer) {
356 await update($, events, list => list.map(e => (e.id === id ? { ...e, read: !next } : e)))
357 await syncPaneTitle($)
358 return
359 }
360 await invalidate($)
361 await loadScopes($, { force: true })
362}
363
364async function markAllRead($: EngineInterface) {
365 const where = await read($, scope)
366 const body = where.kind === 'all' ? { scope: 'all' } : { scope: where.kind, id: where.id }
367 const answer = await call($, '/events/read-all', { method: 'POST', body })
368 if (!answer) { $.ui.toast('Alarum: could not mark them read'); return }
369 await update($, events, list => list.map(e => (inScope([e], where).length ? { ...e, read: true } : e)))
370 await syncPaneTitle($)
371 await invalidate($)
372 await loadScopes($, { force: true })
373}
374
375async function setScope($: EngineInterface, next: Scope) {
376 await update($, scope, () => next)
377 await update($, isPickingScope, () => false)
378 await update($, selected, () => null)
379 await remember($, 'scope', next)
380 await syncPaneTitle($)
381 // No refetch: the window held covers every project, so a new scope is a new
382 // filter over what is already here.
383}
384
385/**
386 * One level chip on or off. The last one on cannot be turned off: an empty
387 * filter draws an empty band with nothing to say why, and the way to see
388 * nothing is to close the pane.
389 */
390async function toggleLevel($: EngineInterface, level: Level) {
391 const on = await read($, levels)
392 const next = on.includes(level) ? on.filter(l => l !== level) : [...on, level]
393 if (!next.length) { $.ui.toast('Alarum: at least one level has to stay on'); return }
394 const ordered = LEVELS.filter(l => next.includes(l))
395 await update($, levels, () => ordered)
396 await remember($, 'levels', ordered)
397 await syncPaneTitle($)
398}
399
400async function toggleUnread($: EngineInterface) {
401 const next = !(await read($, unreadOnly))
402 await update($, unreadOnly, () => next)
403 await remember($, 'unread', next)
404}
405
406/** ⤴: the event goes into the prompt box, unsent, after whatever is typed. */
407async function investigate($: EngineInterface, event: AlarumEvent) {
408 const text = investigatePrompt(event)
409 const box = await $.prompt.read().catch(() => ({ text: '' }))
410 const filled = await $.prompt
411 .fill({ text: box.text.trim() ? `\n\n${text}` : text, mode: 'append' })
412 .catch(() => ({ isFilled: false }))
413 if (!filled.isFilled) $.ui.toast('Alarum: the prompt box would not take it (a dialog is up?)')
414}
415
416// Open a URL with the host's own opener: no shell, the URL one argument. WSL
417// hands it to Windows (explorer.exe exits 1 even when it opened it); Windows,
418// macOS and Linux their usual one.
419async function openPage($: EngineInterface, url: string) {
420 const openers: string[][] = []
421 if (await $.env.get('WSL_DISTRO_NAME')) openers.push(['explorer.exe', url], ['wslview', url])
422 else if (await $.env.get('OS') === 'Windows_NT') openers.push(['rundll32', 'url.dll,FileProtocolHandler', url])
423 else {
424 const os = await $.process.run(['uname', '-s']).then(r => r.stdout.trim(), () => '')
425 openers.push(os === 'Darwin' ? ['open', url] : ['xdg-open', url])
426 }
427 for (const argv of openers) {
428 try {
429 const { exitCode } = await $.process.run(argv, { timeoutMs: 10_000 })
430 if (exitCode === 0 || argv[0] === 'explorer.exe') return
431 } catch { /* not there: the next one */ }
432 }
433 $.ui.toast(`Alarum: could not open a browser for ${url}`)
434}
435
436/** Which of the two the mod draws into, kept for the next session of this repo. */
437async function saveLayout($: EngineInterface, next: 'band' | 'pane') {
438 await update($, layout, () => next)
439 await remember($, 'layout', next)
440}
441
442/**
443 * The pane's tab: `Alarum`, or `Alarum (3)` while three events the filters
444 * leave are unread. The tab is all that is left of the mod when another
445 * plugin's pane is in front, so the count belongs there.
446 *
447 * Counted like the band's counters: events, not the rows a burst folds into,
448 * and under the scope and the levels in force, which is what is being looked at.
449 */
450async function paneTitle($: EngineInterface): Promise<string> {
451 const where = await read($, scope)
452 const unread = visible(inScope(await read($, events), where), await read($, levels), true).length
453 return unread ? `Alarum (${unread})` : 'Alarum'
454}
455
456/**
457 * Deliver that title, if it changed.
458 *
459 * Opening an id already open retitles it in place, so this is the whole of it.
460 * Called from what moves the count, never from drawing: a render hook may not
461 * write, and a retitle would redraw what is drawing.
462 */
463async function syncPaneTitle($: EngineInterface) {
464 const open = (await $.ui.panes().catch(() => [])).find(p => p.id === PANE)
465 if (!open) return
466 const title = await paneTitle($)
467 if (open.title === title) return
468 await $.ui.open({ id: PANE, title }).catch(() => undefined)
469}
470
471async function toggleLayout($: EngineInterface) {
472 if (await read($, layout) === 'pane') {
473 await saveLayout($, 'band')
474 await $.ui.close({ id: PANE }).catch(() => undefined)
475 return
476 }
477 await saveLayout($, 'pane')
478 await $.ui.open({ id: PANE, title: await paneTitle($) }).catch(() => undefined)
479}
480
481// ── Drawing ─────────────────────────────────────────────────────────────────
482
483/** Nothing to show: a tree of empty Boxes and blank Text, which is what a band with nothing to say draws. */
484function isBlank(node: RenderNode): boolean {
485 if (typeof node === 'string') return node.trim() === ''
486 if (node.type !== 'Box' && node.type !== 'Text') return false
487 return (node.children ?? []).every(isBlank)
488}
489
490/**
491 * Rows a tree takes, roughly: a column adds its children up, a row takes its
492 * tallest, a Text its lines; borders, paddings and margins add theirs. Used to
493 * leave the bands below ours the room they already took.
494 */
495function rowsOf(node: RenderNode): number {
496 if (typeof node === 'string') return node.split('\n').length
497 const props = ('props' in node ? node.props : undefined) as Record<string, unknown> | undefined
498 const children = 'children' in node && Array.isArray(node.children) ? node.children as RenderNode[] : []
499 if (node.type === 'Text') return children.filter(c => typeof c === 'string').join('').split('\n').length
500 if (node.type !== 'Box') return 1
501 const own = children.map(rowsOf)
502 const body = props?.flexDirection === 'column' ? own.reduce((a, b) => a + b, 0) : Math.max(0, ...own)
503 const n = (k: string) => (typeof props?.[k] === 'number' ? props[k] as number : 0)
504 const edges = (k: string) => (n(`${k}Top`) || n(`${k}Y`) || n(k)) + (n(`${k}Bottom`) || n(`${k}Y`) || n(k))
505 return body + edges('padding') + edges('margin') + (props?.borderStyle ? 2 : 0)
506}
507
508
509
510/** A row carries more than its line shows: a message, fields, tags. */
511function hasDetail(row: Row): boolean {
512 return Boolean(row.message) || row.fields.length > 0 || row.tags.length > 0
513}
514
515async function draw(
516 $: EngineInterface,
517 e: Parameters<EngineInterface['ui']['resolve']>[0],
518 opts: { isPane: boolean; maxRows: number; columns: number },
519): Promise<RenderNode | null> {
520 if (!config) return null
521 const { Box, Text, Button } = $.ui.resolve(e)
522
523 // Claude Desktop draws every Button as a native one, in its own font, where
524 // a bare glyph reads as nothing: the brackets and the glyph-only labels are
525 // the terminal's alone. Two widths matter, not one: the header's labels give
526 // way first, the rows' later, since a row carries more of them.
527 const isTerminal = e.surface === 'terminal'
528 const narrow = isTerminal && opts.columns < (opts.isPane ? NARROW_PANE : NARROW)
529 const tight = isTerminal && opts.columns < (opts.isPane ? TIGHT_PANE : TIGHT)
530 const glyph = (g: string) => (isTerminal ? `[${g}]` : g)
531 const chip = narrow ? true : undefined
532
533 const where = await read($, scope)
534 // The window held is account-wide, shared with the other sessions: the scope
535 // is a filter here, like the levels, not a query over there.
536 const all = inScope(await read($, events), where)
537 const shown = await read($, levels)
538 const onlyUnread = await read($, unreadOnly)
539 const rows = group(visible(all, shown, onlyUnread))
540 const at = await read($, now)
541 const problem = await read($, error)
542 const paused = await read($, pausedUntil)
543 const projectList = await read($, projects)
544 const workspaceList = await read($, workspaces)
545 const picking = await read($, isPickingScope)
546 const open = await read($, selected)
547
548 // The orange frame, the band's look, in the pane too (inside the engine's).
549 const frame = { borderStyle: 'round' as const, borderColor: brand, paddingX: 1 }
550
551 const state = (
552 <Box gap={1} flexShrink={0}>
553 <Text color={brand} bold>⩄ Alarum</Text>
554 {problem ? <Text color="error">{problem}</Text> : null}
555 {!problem && paused > at ? <Text color="warning">{narrow ? '⏸' : `⏸ paused ${Math.ceil((paused - at) / 1000)}s`}</Text> : null}
556 </Box>
557 )
558
559 // What a row says about itself besides its title, and never wraps.
560 const factsOf = (row: Row) => [
561 row.repeats > 1 ? `×${row.repeats}` : '',
562 where.kind !== 'project' && row.projectName ? row.projectName : '',
563 row.source ? row.source : '',
564 ago(row.receivedAt, at),
565 ].filter(Boolean).join(' · ')
566
567 // ── The band: one line, always ────────────────────────────────────────────
568 // It sits above the prompt, where every other plugin also wants room, so it
569 // takes one row and keeps it: the title, the latest event the filters leave,
570 // the counters, and the way into the pane, where the work happens.
571 if (!opts.isPane) {
572 const latest = rows[0] ?? null
573 const counters: RenderNode[] = []
574 for (const l of [...LEVELS].reverse()) {
575 const n = all.filter(x => x.level === l).length
576 if (!n) continue
577 if (counters.length) counters.push(<Text dimColor> · </Text>)
578 counters.push(<Text color={COLOR[l]}>{n} {GLYPH[l]}</Text>)
579 }
580 return (
581 <Box {...frame} flexDirection="column">
582 <Box justifyContent="space-between" gap={1}>
583 <Box gap={1} flexShrink={1} overflow="hidden">
584 {state}
585 {latest ? (
586 <Box gap={1} flexShrink={1} overflow="hidden">
587 <Box flexShrink={0}><Text color={COLOR[latest.level]} bold={BOLD[latest.level]}>{GLYPH[latest.level]}</Text></Box>
588 <Text dimColor={latest.read} wrap="truncate-end">{latest.title}</Text>
589 <Box flexShrink={0}><Text dimColor>{factsOf(latest)}</Text></Box>
590 </Box>
591 ) : <Text dimColor>{problem ? '' : 'nothing yet'}</Text>}
592 </Box>
593 <Box gap={1} flexShrink={0}>
594 {counters.length ? <Box gap={0} flexShrink={0}>{counters}</Box> : null}
595 <Button key="layout" label={narrow ? glyph('⇥') : '⇥ Pane'} plain={chip} dimColor onPress={() => toggleLayout($)} />
596 </Box>
597 </Box>
598 </Box>
599 )
600 }
601
602 // ── The pane ──────────────────────────────────────────────────────────────
603 // Two header lines, so the title always has its room: six buttons on one
604 // line pushed it out of a docked pane, which is how this ended up split.
605 const header = (
606 <Box flexDirection="column">
607 <Box justifyContent="space-between" gap={1}>
608 <Box gap={1} flexShrink={1} overflow="hidden">
609 {state}
610 <Box flexShrink={1} overflow="hidden">
611 <Text dimColor wrap="truncate-end">
612 {scopeLabel(where, projectList, workspaceList)}{rows.length ? ` · ${rows.length}` : ' · nothing yet'}
613 </Text>
614 </Box>
615 </Box>
616 <Box gap={narrow ? 0 : 1} flexShrink={0}>
617 <Button key="refresh" label={narrow ? glyph('⟳') : '⟳ Refresh'} plain={chip} dimColor onPress={() => refresh($, { force: true })} />
618 <Button key="layout" label={narrow ? glyph('⇤') : '⇤ Band'} plain={chip} dimColor onPress={() => toggleLayout($)} />
619 </Box>
620 </Box>
621 <Box justifyContent="space-between" gap={1}>
622 {/* One chip per level, as the web inbox filters them: its own glyph in
623 its own colour, lit when the level is on and dim when it is not.
624 Glyphs rather than names, so five chips cost five cells. */}
625 <Box gap={narrow ? 0 : 1} flexShrink={0}>
626 {LEVELS.map(l => {
627 const on = shown.includes(l)
628 return (
629 <Button
630 key={`level:${l}`}
631 label={glyph(GLYPH[l])}
632 plain
633 dimColor={!on}
634 onPress={() => toggleLevel($, l)}
635 >
636 <Text color={on ? COLOR[l] : undefined} bold={on && BOLD[l]} dimColor={!on}>{glyph(GLYPH[l])}</Text>
637 </Button>
638 )
639 })}
640 </Box>
641 {/* An active filter is drawn as the surface marks a control that is on. */}
642 <Box gap={narrow ? 0 : 1} flexShrink={0}>
643 <Button
644 key="scope"
645 label={narrow ? glyph('⊙') : '⊙ Scope'}
646 plain={where.kind === 'all' ? chip : undefined}
647 variant={where.kind === 'all' ? undefined : 'primary'}
648 dimColor={where.kind === 'all'}
649 onPress={() => update($, isPickingScope, v => !v)}
650 />
651 <Button
652 key="unread"
653 label={narrow ? glyph(onlyUnread ? '●' : '○') : (onlyUnread ? '● unread' : '○ all')}
654 plain={onlyUnread ? undefined : chip}
655 variant={onlyUnread ? 'primary' : undefined}
656 dimColor={!onlyUnread}
657 onPress={() => toggleUnread($)}
658 />
659 <Button key="allread" label={narrow ? glyph('✓') : '✓ Mark all'} plain={chip} dimColor onPress={() => markAllRead($)} />
660 </Box>
661 </Box>
662 </Box>
663 )
664
665 // A dim rule sets the controls apart from what they act on, after a blank
666 // line: the rule hard against the buttons reads as their underline.
667 const rule = (
668 <Box marginTop={1}>
669 <Text dimColor wrap="truncate-end">{'─'.repeat(Math.max(10, opts.columns - 4))}</Text>
670 </Box>
671 )
672 // ── The scope picker takes the list's place while it is open ──────────────
673 if (picking) {
674 const choices: RenderNode[] = [
675 <Button key="scope:all" label="⊙ All projects" plain={where.kind === 'all' ? undefined : true} variant={where.kind === 'all' ? 'primary' : undefined} dimColor={where.kind !== 'all'} onPress={() => setScope($, { kind: 'all' })} />,
676 ]
677 for (const w of workspaceList) {
678 const here = where.kind === 'workspace' && where.id === w.id
679 choices.push(
680 <Button key={`scope:w:${w.id}`} plain={here ? undefined : true} variant={here ? 'primary' : undefined} dimColor={!here} onPress={() => setScope($, { kind: 'workspace', id: w.id })}>
681 {w.name}{w.unreadCount ? ` · ${w.unreadCount}` : ''}
682 </Button>,
683 )
684 for (const p of projectList.filter(x => x.workspaceId === w.id)) {
685 const mine = where.kind === 'project' && where.id === p.id
686 choices.push(
687 <Button key={`scope:p:${p.id}`} plain={mine ? undefined : true} variant={mine ? 'primary' : undefined} dimColor={!mine} onPress={() => setScope($, { kind: 'project', id: p.id })}>
688 {` ${p.name}`}{p.unreadCount ? ` · ${p.unreadCount}` : ''}
689 </Button>,
690 )
691 }
692 }
693 // Every project, drawn whole. The pane's body is a window over the tree and
694 // the engine scrolls it (the wheel, the arrows while it holds the keys), so
695 // cutting the list here only made the last projects unreachable.
696 return (
697 <Box {...frame} flexDirection="column">
698 {header}
699 {rule}
700 {choices}
701 </Box>
702 )
703 }
704
705 /**
706 * What an event carries beyond its line, laid out as the web inbox lays it
707 * out: when it landed, its message, then its fields as a grid of dim labels
708 * over their values, its tags, its link. Two columns where there is room for
709 * them, one where there is not.
710 */
711 const indent = isTerminal ? 4 : 2
712 const inner = Math.max(20, opts.columns - indent - 4)
713 const columns = inner >= 64 ? 2 : 1
714 const cellWidth = Math.floor(inner / columns)
715 const stamp = (unix: number) => {
716 // UTC, and said so: a terminal has no business guessing a timezone, and a
717 // wrong one on an incident timestamp is worse than an explicit offset.
718 try { return `${new Date(unix * 1000).toISOString().slice(0, 19).replace('T', ' ')} UTC` } catch { return '' }
719 }
720
721 const detailOf = (row: Row) => {
722 const fields = row.fields.slice(0, 12)
723 const grid: Row['fields'][] = []
724 for (let i = 0; i < fields.length; i += columns) grid.push(fields.slice(i, i + columns))
725 return (
726 <Box key={`detail:${row.id}`} flexDirection="column" marginLeft={indent} marginTop={1}>
727 <Text dimColor>{stamp(row.receivedAt)}</Text>
728 {row.message ? <Box marginTop={1}><Text wrap="wrap">{row.message.slice(0, 800)}</Text></Box> : null}
729 {grid.length ? (
730 <Box flexDirection="column" marginTop={1}>
731 {grid.map((pair, i) => (
732 // A blank line between pairs: run together, a value and the next
733 // label read as one block and the grid stops being a grid.
734 <Box key={`${row.id}:grid:${i}`} flexDirection="column" marginBottom={i < grid.length - 1 ? 1 : 0}>
735 <Box gap={2}>
736 {pair.map(f => (
737 <Box key={`${row.id}:l:${f.name}`} width={cellWidth}>
738 <Text dimColor wrap="truncate-end">{f.name.toUpperCase()}</Text>
739 </Box>
740 ))}
741 </Box>
742 <Box gap={2}>
743 {pair.map(f => (
744 <Box key={`${row.id}:v:${f.name}`} width={cellWidth}>
745 <Text wrap="truncate-end">{f.value}</Text>
746 </Box>
747 ))}
748 </Box>
749 </Box>
750 ))}
751 </Box>
752 ) : null}
753 {row.tags.length ? (
754 <Box marginTop={1}><Text dimColor>{row.tags.map(t => ` ${t} `).join(' ')}</Text></Box>
755 ) : null}
756 {row.url ? <Text dimColor wrap="truncate-end">{row.url}</Text> : null}
757 </Box>
758 )
759 }
760
761 /**
762 * One event, over two lines: what it is on top (opener, level, title), what
763 * it says about itself underneath. The title IS the link to its page on
764 * Alarum, as a ticket's id is in the Floatee mod, which saves a button.
765 */
766 const rowOf = (row: Row) => {
767 const isOpen = open === row.id
768 const title = <Text dimColor={row.read} wrap="truncate-end">{row.title}</Text>
769 return (
770 <Box key={`row:${row.id}`} flexDirection="column">
771 <Box justifyContent="space-between" gap={2}>
772 <Box gap={1} flexShrink={1} overflow="hidden">
773 {/* The opener is the row's leftmost thing, and its slot stays there
774 (blank) for an event with nothing more, so the titles line up. */}
775 {hasDetail(row)
776 ? <Button key={`more:${row.id}`} label={glyph(isOpen ? '▴' : '▾')} plain dimColor onPress={() => update($, selected, v => (v === row.id ? null : row.id))} />
777 : <Text>{isTerminal ? ' ' : ' '}</Text>}
778 <Box flexShrink={0}><Text color={COLOR[row.level]} bold={BOLD[row.level]}>{GLYPH[row.level]}</Text></Box>
779 {row.webUrl
780 ? <Button key={`title:${row.id}`} label={row.title} plain onPress={() => openPage($, row.webUrl ?? '')}>{title}</Button>
781 : title}
782 </Box>
783 {/* All three keep their chrome and a space between them at any width.
784 Drawn bare with brackets of our own and no gap, as they were, a
785 narrow pane ran them into one another and into the real buttons. */}
786 <Box gap={1} flexShrink={0}>
787 {row.url ? <Button key={`link:${row.id}`} label={tight ? '⇗' : '⇗ Link'} dimColor onPress={() => openPage($, row.url ?? '')} /> : null}
788 {/* Ask never shrinks: it is the one action worth naming, and a bare
789 glyph says nothing about what it does. */}
790 <Button key={`go:${row.id}`} label="⤴ Ask" variant="primary" onPress={() => investigate($, row)} />
791 {/* A checkbox, and nothing else: empty while the event is unread,
792 ticked once it is, dim once there is nothing left to do with it.
793 The label cannot change under the pointer (a hover restyles an
794 element or reveals a hidden one, it never swaps one for
795 another), so the state lives in the box itself. */}
796 <Button key={`read:${row.id}`} label={row.read ? '✓' : ' '} dimColor={row.read} onPress={() => markRead($, row.id, !row.read)} />
797 </Box>
798 </Box>
799 {/* The message's first line, under the title: for a deploy or a CI run
800 it is what actually says what happened, and reading it should not
801 cost an expand. Its own line, so the facts keep theirs. */}
802 {row.message ? (
803 <Box marginLeft={isTerminal ? 4 : 2}>
804 <Text dimColor={row.read} wrap="truncate-end">{firstLine(row.message)}</Text>
805 </Box>
806 ) : null}
807 <Box marginLeft={isTerminal ? 4 : 2}>
808 <Text dimColor wrap="truncate-end">{factsOf(row)}</Text>
809 </Box>
810 {isOpen && hasDetail(row) ? detailOf(row) : null}
811 </Box>
812 )
813 }
814
815 // The whole window, for the same reason as the picker: the pane scrolls, and
816 // an event cut off here is an event nothing can reach. The list is bounded
817 // by what was asked for (WINDOW), not by the rows on screen.
818 return (
819 <Box {...frame} flexDirection="column">
820 {header}
821 {rule}
822 {rows.length === 0 && !problem ? <Text dimColor>Nothing yet. Events land here as they arrive.</Text> : null}
823 {/* The pane has room: a blank line between events, for the eye. */}
824 <Box flexDirection="column" gap={1}>
825 {rows.map(rowOf)}
826 </Box>
827 </Box>
828 )
829}
830// ── Wiring ──────────────────────────────────────────────────────────────────
831
832export const register: Register = on => {
833 on('session.start', async ($, e, next) => {
834 config = await findConfig($)
835 if (!config) return next(e)
836 fingerprint = fingerprintOf(config)
837 const poll = config.poll
838 const fromFile = config.scope
839
840 // A reload happens between turns: no turn is running.
841 await update($, isBusy, () => false)
842 await update($, isPickingScope, () => false)
843 await readBrand($)
844
845 await loadScopes($)
846 const stored = await recall($, 'scope')
847 const opening = openingScope(stored, fromFile, await read($, projects), await read($, workspaces))
848 await update($, scope, () => opening)
849
850 // The filters, as this repo last left them. A stored list that no longer
851 // names a level (a renamed one, a hand-edited store) falls back to all of
852 // them rather than to an empty band.
853 const savedLevels = await recall($, 'levels')
854 const kept = Array.isArray(savedLevels) ? LEVELS.filter(l => savedLevels.includes(l)) : []
855 await update($, levels, () => (kept.length ? kept : LEVELS))
856 const savedUnread = await recall($, 'unread')
857 await update($, unreadOnly, () => savedUnread === true)
858
859 const savedLayout = await recall($, 'layout')
860 await update($, layout, () => (savedLayout === 'pane' ? 'pane' : 'band'))
861 if (savedLayout === 'pane') void $.ui.open({ id: PANE, title: 'Alarum' }).catch(() => undefined)
862
863 await refresh($)
864
865 // One dispatch per tick, the read itself paced by the config: twice as
866 // often while Claude is idle, since that is when someone is watching.
867 $.clock.every(TICK_MS, async () => {
868 const at = await $.clock.now().catch(() => lastRefresh)
869 await update($, now, () => at)
870 const every = ((await read($, isBusy)) ? poll : poll / 2) * 1000
871 if (at - lastRefresh >= every - 500) await refresh($)
872 })
873 return next(e)
874 })
875
876 on('turn.start', async ($, e, next) => {
877 if (config) await update($, isBusy, () => true)
878 return next(e)
879 })
880
881 on('turn.complete', async ($, e, next) => {
882 // A subagent's turn ends inside the main one: nothing to do.
883 if (!config || (e as { agentId?: string }).agentId) return next(e)
884 await update($, isBusy, () => false)
885 await refresh($).catch(() => undefined)
886 return next(e)
887 })
888
889 // The guards, on every tool call, the subagents' included.
890 on('tool.call', async ($, e, next) => {
891 const no = refusal(e as never)
892 if (no) return { deny: `alarum: ${no}` }
893
894 const ran = await next(e)
895 if (ran.deny !== undefined) return ran
896
897 // A token in the output is masked. Our own answer (no `ref`), so core maps
898 // the masked record instead of reusing its messages.
899 TOKEN_RE.lastIndex = 0
900 if (!TOKEN_RE.test((ran.text ?? '') + JSON.stringify(ran.result ?? ''))) return ran
901 if (ran.isError) return { isError: true, result: mask(ran.result), text: mask(ran.text ?? '') as string }
902 return { result: mask(ran.result) as typeof ran.result, ...(ran.context ? { context: ran.context } : {}) }
903 }).catch(($, e, next) => {
904 // The hook failed, or is being asked again from under its own call. A hook
905 // that guards is skipped on a throw unless its handler answers for it, so
906 // it judges once more from the event alone (refusal() is pure and needs
907 // nothing else). Only `.alarum` is refused here: a bug in this mod must
908 // not hand the token over, and must not brick a session either.
909 if (next.called) return next(e)
910 try {
911 const no = refusal(e as never)
912 if (no) return { deny: `alarum: ${no}` }
913 } catch { /* judged nothing: let it through */ }
914 return next(e)
915 })
916
917 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
918 if (!config || e.props.hasSurvey) return next(e)
919 // The pane shows the list: the band leaves it the place. A pane still
920 // waiting for room leaves the band standing in.
921 if (await read($, layout) === 'pane' && (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)) return next(e)
922
923 // What the plugins beneath drew (the Floatee mod, a bleh kit band, the
924 // engine's own). Answering without it would make this mod the only band
925 // anyone can see, which is not ours to decide: we stack under it instead,
926 // and ask for the room it did not take.
927 const below = await next(e)
928 const others = isBlank(below) ? null : below
929 const maxRows = Math.max(0, e.props.maxRows - (others ? rowsOf(others) : 0))
930 const tree = await draw($, e, { isPane: false, maxRows, columns: e.props.bodyColumns })
931 if (!tree || !others) return tree ?? below
932 const { Box } = $.ui.resolve(e)
933 return <Box flexDirection="column">{others}{tree}</Box>
934 })
935
936 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
937 const tree = await draw($, e, { isPane: true, maxRows: e.props.scroll.bodyRows, columns: e.props.bodyColumns })
938 const { Text } = $.ui.resolve(e)
939 return tree ?? <Text dimColor>No .alarum here: nothing to show.</Text>
940 })
941
942 // Closed by hand: back to the band, remembered. Remembering is the whole of
943 // it, so a failure here must never keep a pane the person just closed open.
944 on('ui.close', async ($, e, next) => {
945 if (config && e.id === PANE && e.origin.kind === 'person') await saveLayout($, 'band')
946 return next(e)
947 }).catch(($, e, next) => next(e))
948}
949hooks/config.ts 56 lines1// The repo's `.alarum`, read by the mod only: the token stays in module
2// memory, never in $.state, a log, a prompt or a toast.
3import type { Level, Scope } from '../types'
4
5export type Config = {
6 token: string
7 /** The agent API's base, without a trailing slash. */
8 api: string
9 /** The web app, where an event's page lives (`web=`; else derived from `api`). */
10 web: string
11 /** Where the band looks when nothing has been chosen in it yet. */
12 scope: Scope
13 /**
14 * `levels=` pins which levels toast, for every project. Empty (the default)
15 * means each project's own notification rules decide, which is what the
16 * account already configured in the app.
17 */
18 levels: Level[]
19 /** Seconds between two reads while a turn runs; half that while Claude is idle. */
20 poll: number
21}
22
23const LEVELS: Level[] = ['info', 'success', 'warn', 'error', 'critical']
24
25const seconds = (v: string | undefined, fallback: number) => {
26 const n = Number(v)
27 // A one-second poll would spend the token's whole per-minute allowance, and
28 // an hour-long one is not a live band any more.
29 return Number.isFinite(n) && n >= 10 && n <= 600 ? Math.round(n) : fallback
30}
31
32/** `key=value` lines, `#` comments: the same shape as `.floatee`. */
33export function parseConfig(text: string): Config | null {
34 const kv: Record<string, string> = {}
35 for (const line of text.split(/\r?\n/)) {
36 if (line.trim().startsWith('#')) continue
37 const m = line.match(/^\s*([a-z_]+)\s*=\s*(.*?)\s*$/i)
38 if (m && m[1]) kv[m[1].toLowerCase()] = m[2] ?? ''
39 }
40 if (!kv.token) return null
41
42 const api = (kv.api || 'https://alarum.me/api/v1').replace(/\/+$/, '')
43 const scope: Scope = kv.project ? { kind: 'project', id: kv.project }
44 : kv.workspace ? { kind: 'workspace', id: kv.workspace }
45 : { kind: 'all' }
46
47 return {
48 token: kv.token,
49 api,
50 web: (kv.web || api.replace(/\/api\/v1$/, '')).replace(/\/+$/, ''),
51 scope,
52 levels: (kv.levels ?? '').split(',').map(s => s.trim().toLowerCase()).filter((s): s is Level => (LEVELS as string[]).includes(s)),
53 poll: seconds(kv.poll, 30),
54 }
55}
56hooks/guards.ts 55 lines1// What the mod enforces on every tool call, the subagents' included:
2//
3// G1 the agent never reads `.alarum` (the token would enter the context)
4// G2 an Alarum token printed by any tool is masked before the model reads it
5//
6// Both exist for the same reason: an agent token reads every project its owner
7// can reach, so it must never end up in a transcript, a log or a commit.
8
9// alrm_at_ + 43 base64url chars is the agent token; alrm_ + 43 the ingestion
10// one. Both are masked: an ingestion token pasted into a curl, or printed by a
11// `cat` of a config, is just as much a secret, and the mod is the only thing
12// watching tool output here. Shorter look-alikes (doc placeholders, the
13// prefix+suffix the app displays) are left alone so files stay readable.
14export const TOKEN_RE = /alrm_(?:at_)?[A-Za-z0-9_-]{40,}/g
15const MASK = 'alrm_[hidden by the alarum mod]'
16
17// `.alarum` as a path, not as a word inside another name, nor an escaped
18// regex (`grep "\.alarum"`).
19const DOTFILE_RE = /(^|[^\w.\\-])\.alarum(?![\w.-])/
20// Prose that merely names the file (a commit message, a ticket body): heredocs
21// and quoted strings holding a space. A path is a single word.
22const PROSE_RE = /<<-?\s*(['"]?)(\w+)\1[\s\S]*?\n\s*\2(?=\s|$)|"[^"\n]*\s[^"]*"|'[^'\n]*\s[^']*'/g
23// An existence test is fine: it prints nothing of the file.
24const EXISTS_RE = /(test\s+-[efs]|\[\s+-[efs])\s+["']?[^\s;&|"']*\.alarum["']?(\s*\])?/g
25
26/** A copy of a tool's record with every token masked; the same value when none. */
27export function mask(value: unknown): unknown {
28 if (typeof value === 'string') return value.replace(TOKEN_RE, MASK)
29 if (Array.isArray(value)) return value.map(mask)
30 if (value && typeof value === 'object') {
31 return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, mask(v)]))
32 }
33 return value
34}
35
36const READ_NOTE = 'The alarum mod keeps `.alarum` out of the conversation: it holds an API token that reads every project of the account. The band reads it itself; nothing in the repo needs its contents.'
37
38/** Why this call must not run, or null. */
39export function refusal(e: { tool: string } & Record<string, unknown>): string | null {
40 const str = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
41
42 if (['Read', 'Edit', 'NotebookEdit'].includes(e.tool) && /(^|[\\/])\.alarum$/.test(str('file_path') || str('notebook_path'))) return READ_NOTE
43 if (e.tool === 'Grep' && /(^|[\\/])\.alarum$/.test(str('path'))) return READ_NOTE
44 if (e.tool !== 'Bash') return null
45
46 const command = str('command')
47 // A write to the file is the one Bash case we allow: creating or editing it
48 // is how a person sets the mod up, and it prints nothing back.
49 if (/>\s*["']?[^\s"';|&]*\.alarum\b/.test(command)) return null
50 if (DOTFILE_RE.test(command.replace(PROSE_RE, '').replace(EXISTS_RE, ''))) {
51 return `${READ_NOTE} To check that it exists: \`test -f .alarum\`.`
52 }
53 return null
54}
55hooks/investigate.ts 38 lines1// The text ⤴ Investigate puts in the prompt box. Pure, so what the agent is
2// handed can be asserted without an engine.
3import type { AlarumEvent } from '../types'
4
5const MAX_MESSAGE = 1500
6const MAX_FIELDS = 10
7const MAX_VALUE = 200
8
9const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
10
11/**
12 * An event as a question to work on, not as a dump. The order is what someone
13 * reading it needs first: what broke, where it came from, then the detail.
14 *
15 * It is written into the prompt box and NOT sent: the person adds what they
16 * know (what they were doing, which branch) before handing it over, which is
17 * usually worth more than anything the event carries.
18 */
19export function investigatePrompt(e: AlarumEvent): string {
20 const lines = [
21 `Investigate this Alarum event (${e.level}), from the project "${e.projectName}":`,
22 '',
23 `Title: ${e.title}`,
24 ]
25 if (e.source) lines.push(`Source: ${e.source}`)
26 if (e.tags.length) lines.push(`Tags: ${e.tags.join(', ')}`)
27 for (const f of e.fields.slice(0, MAX_FIELDS)) {
28 lines.push(`${f.name}: ${clip(f.value.replace(/\s+/g, ' ').trim(), MAX_VALUE)}`)
29 }
30 if (e.url) lines.push(`Link: ${e.url}`)
31 if (e.webUrl) lines.push(`Event: ${e.webUrl}`)
32 if (e.message) {
33 lines.push('', 'Message:', clip(e.message.trim(), MAX_MESSAGE))
34 }
35 lines.push('', 'Find what caused it in this repository, and tell me before changing anything.')
36 return lines.join('\n')
37}
38hooks/events.ts 202 lines1// What the band shows and what it announces, as pure functions: grouping a
2// burst, counting by level, deciding what is new, and reading a payload the
3// API sent. Nothing here touches the engine, so it is all unit-testable.
4import type { AlarumEvent, Level, Project, Scope, Workspace } from '../types'
5
6export const LEVELS: Level[] = ['info', 'success', 'warn', 'error', 'critical']
7
8/** Lowest to highest. A `minLevel` filter keeps everything at or above it. */
9export const RANK: Record<Level, number> = { info: 0, success: 1, warn: 2, error: 3, critical: 4 }
10
11/**
12 * One glyph per level, all single-width: an emoji-presentation character (⛔,
13 * 🔴) would take two cells in a terminal and shift every column after it.
14 */
15export const GLYPH: Record<Level, string> = { info: 'ℹ', success: '✓', warn: '!', error: '✖', critical: '⨳' }
16
17/** The loud levels are drawn bold: it is what makes a bare `!` read as a warning. */
18export const BOLD: Record<Level, boolean> = { info: false, success: false, warn: true, error: true, critical: true }
19
20/**
21 * The web inbox's own colours (composables/useLevels.ts), to the digit, so a
22 * level is the same colour in the terminal as in the browser. Spelled rgb(),
23 * as a theme spells its colours: the same value as a hex came out duller in
24 * some terminals.
25 */
26export const COLOR: Record<Level, string> = {
27 info: 'rgb(52,152,219)', // #3498DB
28 success: 'rgb(46,204,113)', // #2ECC71
29 warn: 'rgb(230,126,34)', // #E67E22
30 error: 'rgb(231,76,60)', // #E74C3C
31 critical: 'rgb(155,89,182)', // #9B59B6
32}
33
34/** A row of the band: an event, plus how many identical ones it stands for. */
35export type Row = AlarumEvent & { repeats: number }
36
37const isLevel = (v: unknown): v is Level => typeof v === 'string' && (LEVELS as string[]).includes(v)
38
39/**
40 * One event from the API's JSON, or null when the shape is not what we expect.
41 * Defensive on purpose: a band that throws on one odd row would take the whole
42 * list down with it.
43 */
44export function toEvent(raw: unknown): AlarumEvent | null {
45 if (!raw || typeof raw !== 'object') return null
46 const r = raw as Record<string, unknown>
47 if (typeof r.id !== 'string' || typeof r.title !== 'string' || !isLevel(r.level)) return null
48 const project = (r.project ?? {}) as Record<string, unknown>
49 const fields = Array.isArray(r.fields) ? r.fields : []
50 return {
51 id: r.id,
52 level: r.level,
53 title: r.title,
54 message: typeof r.message === 'string' && r.message !== '' ? r.message : null,
55 source: typeof r.source === 'string' && r.source !== '' ? r.source : null,
56 tags: Array.isArray(r.tags) ? r.tags.filter((t): t is string => typeof t === 'string') : [],
57 fields: fields
58 .map(f => (f && typeof f === 'object' ? f as Record<string, unknown> : {}))
59 .filter(f => typeof f.name === 'string' && typeof f.value === 'string')
60 .map(f => ({ name: String(f.name), value: String(f.value) })),
61 receivedAt: typeof r.receivedAt === 'number' ? r.receivedAt : 0,
62 read: r.read === true,
63 projectId: typeof project.id === 'string' ? project.id : '',
64 projectName: typeof project.name === 'string' ? project.name : '',
65 workspaceId: typeof project.workspaceId === 'string' ? project.workspaceId : '',
66 url: typeof r.url === 'string' ? r.url : null,
67 webUrl: typeof r.webUrl === 'string' ? r.webUrl : null,
68 }
69}
70
71/**
72 * Fold CONSECUTIVE repeats into one row carrying a count. Only neighbours are
73 * folded, so the list stays in true chronological order: a burst collapses, an
74 * event that comes back an hour later keeps its own line.
75 */
76export function group(events: AlarumEvent[]): Row[] {
77 const rows: Row[] = []
78 for (const e of events) {
79 const last = rows[rows.length - 1]
80 if (last && last.title === e.title && last.source === e.source && last.level === e.level && last.projectId === e.projectId) {
81 // The newest of the burst keeps the row (the list is newest first), and
82 // an unread one in the group marks the row unread.
83 last.repeats += 1
84 last.read = last.read && e.read
85 continue
86 }
87 rows.push({ ...e, repeats: 1 })
88 }
89 return rows
90}
91
92/** How many events of each level, for the folded band's one line. */
93export function counts(events: AlarumEvent[]): Record<Level, number> {
94 const out: Record<Level, number> = { info: 0, success: 0, warn: 0, error: 0, critical: 0 }
95 for (const e of events) out[e.level] += 1
96 return out
97}
98
99/** What the filters leave: a level that is on, and unread when that is asked. */
100export function visible(events: AlarumEvent[], levels: Level[], unreadOnly: boolean): AlarumEvent[] {
101 return events.filter(e => levels.includes(e.level) && (!unreadOnly || !e.read))
102}
103
104export function newestAt(events: AlarumEvent[]): number {
105 return events.reduce((max, e) => (e.receivedAt > max ? e.receivedAt : max), 0)
106}
107
108/**
109 * What to toast: events that arrived after the baseline, whose level the
110 * account asked to be notified about for THEIR project, AND which the filters
111 * leave on screen. Filtering down to errors means "only this", interruptions
112 * included; a filter can only ever take announcements away, never add one the
113 * project's rules refuse.
114 *
115 * `override` (the `levels=` line) replaces the project's rules when set, and
116 * the shown levels still apply on top.
117 *
118 * A project the listing did not carry (a token that cannot reach it, a listing
119 * that failed) announces nothing rather than guessing: a silent band is a
120 * smaller surprise than one that shouts about the wrong thing.
121 */
122export function announce(
123 events: AlarumEvent[],
124 seenUpTo: number,
125 projects: Project[],
126 override: Level[],
127 shown: Level[],
128): AlarumEvent[] {
129 if (seenUpTo === 0) return [] // first load: the window is the baseline, not news
130 const push = new Map(projects.map(p => [p.id, p.push]))
131 return events
132 .filter(e => e.receivedAt > seenUpTo)
133 .filter(e => shown.includes(e.level))
134 .filter(e => (override.length ? override.includes(e.level) : push.get(e.projectId)?.[e.level] === true))
135 .sort((a, b) => a.receivedAt - b.receivedAt)
136}
137
138/** A message's first line, for the row that shows it without expanding. */
139export function firstLine(message: string): string {
140 const line = message.split('\n').find(l => l.trim() !== '') ?? ''
141 return line.trim()
142}
143
144/** '2m', '3h', '4d': an age a glance can read, counted from `nowMs`. */
145export function ago(receivedAt: number, nowMs: number): string {
146 const s = Math.max(0, Math.round(nowMs / 1000) - receivedAt)
147 if (s < 60) return `${s}s`
148 if (s < 3600) return `${Math.floor(s / 60)}m`
149 if (s < 86400) return `${Math.floor(s / 3600)}h`
150 return `${Math.floor(s / 86400)}d`
151}
152
153/**
154 * The scope, applied HERE rather than in the query. Every session reads the
155 * same account-wide window so they can share one request (#695), and each one
156 * then keeps what its own scope covers.
157 */
158export function inScope(events: AlarumEvent[], scope: Scope): AlarumEvent[] {
159 if (scope.kind === 'all') return events
160 if (scope.kind === 'project') return events.filter(e => e.projectId === scope.id)
161 return events.filter(e => e.workspaceId === scope.id)
162}
163
164/** How the scope reads in the band's header. */
165export function scopeLabel(scope: Scope, projects: Project[], workspaces: Workspace[]): string {
166 if (scope.kind === 'project') return projects.find(p => p.id === scope.id)?.name ?? 'project'
167 if (scope.kind === 'workspace') return workspaces.find(w => w.id === scope.id)?.name ?? 'workspace'
168 return 'All projects'
169}
170
171/**
172 * A scope naming a project or workspace by id OR by slug / name, turned into
173 * one naming it by id, or null when nothing matches. `.alarum` is written by
174 * hand, so `project=alarum` has to work as well as a copied uuid.
175 */
176export function resolveScope(scope: Scope, projects: Project[], workspaces: Workspace[]): Scope | null {
177 if (scope.kind === 'all') return scope
178 if (scope.kind === 'project') {
179 const p = projects.find(x => x.id === scope.id) ?? projects.find(x => x.slug === scope.id)
180 return p ? { kind: 'project', id: p.id } : null
181 }
182 const w = workspaces.find(x => x.id === scope.id)
183 ?? workspaces.find(x => x.name.toLowerCase() === scope.id.toLowerCase())
184 return w ? { kind: 'workspace', id: w.id } : null
185}
186
187/**
188 * The scope the band opens on: what was chosen here before, else what `.alarum`
189 * names, and `all` when neither resolves any more (a revoked share, a renamed
190 * project) rather than an empty band with nothing to explain it.
191 */
192export function openingScope(stored: unknown, fromFile: Scope, projects: Project[], workspaces: Workspace[]): Scope {
193 if (stored && typeof stored === 'object') {
194 const s = stored as Scope
195 if (s.kind === 'all' || s.kind === 'project' || s.kind === 'workspace') {
196 const found = resolveScope(s, projects, workspaces)
197 if (found) return found
198 }
199 }
200 return resolveScope(fromFile, projects, workspaces) ?? { kind: 'all' }
201}
202types/index.d.ts 75 lines1export type Level = 'info' | 'success' | 'warn' | 'error' | 'critical'
2
3/** One event as /api/v1/events publishes it, trimmed to what the band draws. */
4export type AlarumEvent = {
5 id: string
6 level: Level
7 title: string
8 message: string | null
9 source: string | null
10 tags: string[]
11 fields: { name: string; value: string }[]
12 /** Unix seconds, as the API reports them. */
13 receivedAt: number
14 read: boolean
15 projectId: string
16 projectName: string
17 /** Its project's workspace, so a workspace scope can be applied locally. */
18 workspaceId: string
19 /** The link the sender attached, if any. */
20 url: string | null
21 /** The event's page in the web app. */
22 webUrl: string | null
23}
24
25/**
26 * A project the token can reach. `push` is what the account asked to be
27 * notified about for it, and is what the band toasts on.
28 */
29export type Project = {
30 id: string
31 workspaceId: string
32 workspaceName: string
33 name: string
34 /** What a hand-written `.alarum` is likely to name the project by. */
35 slug: string
36 unreadCount: number
37 push: Record<Level, boolean>
38}
39
40export type Workspace = { id: string; name: string; unreadCount: number }
41
42/** What the band is looking at: everything, one workspace, or one project. */
43export type Scope = { kind: 'all' } | { kind: 'workspace'; id: string } | { kind: 'project'; id: string }
44
45declare module 'claude-code' {
46 interface PluginState {
47 alarum: {
48 /** The window the band draws, newest first. */
49 events: AlarumEvent[]
50 /** Everything the token can reach, for the scope picker and the push rules. */
51 projects: Project[]
52 workspaces: Workspace[]
53 scope: Scope
54 /** Last fetch problem shown in the band, null when fine. */
55 error: string | null
56 /** A model turn is running: the band reads less often. */
57 isBusy: boolean
58 /** The scope picker replaces the list while it is open. */
59 isPickingScope: boolean
60 layout: 'band' | 'pane'
61 /** The event whose detail the pane draws. */
62 selected: string | null
63 /** The levels drawn, one chip each, as the web inbox filters them. */
64 levels: Level[]
65 unreadOnly: boolean
66 /** When a rate-limit pause (429) ends, in ms; 0 when none. */
67 pausedUntil: number
68 /** receivedAt of the newest event already announced: the toast baseline. */
69 seenUpTo: number
70 /** The time the band last ticked, in ms: what the ages are counted to. */
71 now: number
72 }
73 }
74}
75