SLOPSHOPPER

alarum

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

newpanebandguardtoastprocess
★ 1v1.0.4MITupdated 2026-10-13FrBmt-BIGetNouf/alarum-claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · alarum
│ ┃ alarum ✕ › fix the failing auth test and add an audit log call │ ┃ No .alarum here: nothing to show. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · alarum
No .alarum here: nothing to show.
README

Alarum mod for Claude Code

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:

  • every event of the account, newest first, with its level, project, source and age
  • its tab counts what the filters leave unread: Alarum (3), and Alarum at zero
  • ⤴ Ask drops an event into the prompt box, unsent, so a failed deploy becomes the next thing you ask about
  • the title opens the event's page in Alarum; ⇗ Link opens whatever the sender attached
  • ▾ unfolds what the event carries: its message, its fields as a grid, its tags
  • ✓ / ✗ marks one read, ✓ Mark all the whole scope, both reflected in the web inbox
  • ⊙ Scope narrows to one workspace or one project; a chip per level and an unread filter do the rest, and they are remembered per repository
  • a toast on arrival, for the levels your account already asks to be notified about for that project
  • a burst of identical events folds into one row with a count

Several sessions open at once share one read: whichever finds the window stale fetches it, the others draw what it left.

Install

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

Requirements

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:

LineWhat 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,errorpins which levels toast, instead of following each project's notification rules
poll=30seconds 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.

Safety

  • The mod reads .alarum itself and refuses to let the agent read it, so the token never enters the conversation
  • Any Alarum token printed by a command is masked before the model sees it
  • Nothing from that file is ever written to the session's state, and the shared window is filed under a hash of the token, never the token
  • No .alarum in the repo means no pane and no request at all

License

MIT, see LICENSE.

Source 6 files
hooks/register.tsx 949 lines
1// 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}
949
hooks/config.ts 56 lines
1// 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}
56
hooks/guards.ts 55 lines
1// 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}
55
hooks/investigate.ts 38 lines
1// 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}
38
hooks/events.ts 202 lines
1// 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}
202
types/index.d.ts 75 lines
1export 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