SLOPSHOPPER

hook-notices

Fold this session's hook messages into one line above the prompt; open them in a scrollable pane.

newpanebandcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hook-notices
│ ┃ Hook messages ✕ › fix the failing auth test and add an audit log call │ ┃ No hook messages yet │ ⏺ 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 │ │ › /hook-notices │ ⎿ hook-notices: Hook messages open │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Hook messages
No hook messages yet
README

hook-notices

Hook messages — a SessionStart banner, a reminder on every Stop, a lint warning after an edit — normally print as rows in the transcript, between the work you are reading. hook-notices folds them into one line above the prompt:

🪝 Hook messages · 3 · ⚠ 1 · deploy check failed                    Open ›

The count is what you have not read yet, ⚠ counts alerts among them, and the headline is the newest alert (or the newest message). Press the line, or run /hook-notices, to open every message of the session in a scrollable pane.

Your hooks have to hand their messages over

hook-notices does not intercept hook output. It shows what hooks write to one file per session, and sets HOOK_NOTICES_SINK=1 in the environment of every hook so a hook can tell it is there:

  • File: $HOOK_NOTICES_DIR/<session_id>.jsonl, default ~/.claude/data/hook-notices/<session_id>.jsonl
  • Line: one JSON object per line — {"ts": "2026-10-09T08:47:58Z", "event": "Stop", "level": "info", "text": "…"} (level is "info" or "alert"; anything else reads as info)
  • A hook that wrote its message should not also print it as a systemMessage, or you will see it twice.

examples/notify.py does this from any hook command (Python 3, standard library only). It reads the hook's own JSON on stdin and exits 2 when hook-notices is not installed, so the hook can fall back:

#!/bin/sh
input=$(cat)
msg='3 lint warnings in src/'
printf '%s' "$input" | ~/path/to/notify.py "$msg"
# 2 = hook-notices is off: show it the usual way (json.dumps does the quoting)
if [ $? -eq 2 ]; then
  python3 -c 'import json, sys; print(json.dumps({"systemMessage": sys.argv[1]}))' "$msg"
fi

Files are small and one per session; nothing deletes them for you.

Options

language: en (default) or zh-TW, from /config or settings:

"pluginConfigs": { "hook-notices@claude-code-mods": { "options": { "language": "zh-TW" } } }

With prompt-band

Installed together with prompt-band, this line and turn-nav's share one row.

Source 4 files
hooks/register.tsx 157 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { HookNotice, HookNoticesChip } from '../types'
4import { bandLabel, fitRow, formatNotice, parseNotices } from './notices'
5import { stringsFor } from './strings'
6import type { Strings } from './strings'
7
8const PANE = 'hook-notices'
9// rows sizes the block seated above the prompt (narrow or main-screen terminals);
10// columns sizes the dock beside a fullscreen transcript (110 columns and up).
11const notices = atom({ plugin: 'hook-notices', key: 'notices' } as const, [] as HookNotice[])
12const seen = atom({ plugin: 'hook-notices', key: 'seen' } as const, 0)
13// Bumped on every open and close so the prompt band re-reads whether the pane is drawn.
14const paneTick = atom({ plugin: 'hook-notices', key: 'paneTick' } as const, 0)
15// This mod's line as the prompt-band mod draws it in the row it shares; null while
16// there is nothing to show. prompt-band cannot list this mod's panes, so it is told.
17const chip = atom({ plugin: 'hook-notices', key: 'chip' } as const, null as HookNoticesChip | null)
18let isPolling = false
19let lastSize: number | undefined
20// How many notices the pane last drew; closing it marks those read, not any the poll
21// added after that draw.
22let shown = 0
23
24async function poll($: EngineInterface, s: Strings): Promise<void> {
25  if (isPolling) return
26  isPolling = true
27  try {
28    const dir = (await $.env.get('HOOK_NOTICES_DIR')) || `${await $.env.get('HOME')}/.claude/data/hook-notices`
29    const path = `${dir}/${await $.session.id()}.jsonl`
30    if (!(await $.fs.exists(path))) return
31    const { size } = await $.fs.stat(path)
32    if (size === lastSize) return
33    const text = await $.fs.read(path)
34    lastSize = size
35    await update($, notices, () => parseNotices(text))
36    await publish($, s)
37  } finally {
38    isPolling = false
39  }
40}
41
42async function isPlaced($: EngineInterface): Promise<boolean> {
43  return (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
44}
45
46async function publish($: EngineInterface, s: Strings): Promise<void> {
47  const list = await read($, notices)
48  const next = list.length === 0 || (await isPlaced($)) ? null : { label: bandLabel(list, await read($, seen), s), action: s.action }
49  await update($, chip, () => next)
50}
51
52// Set by the prompt-band mod, which then draws this mod's line in the row it shares.
53async function hasPromptBand($: EngineInterface): Promise<boolean> {
54  return (await $.env.get('PROMPT_BAND')) === '1'
55}
56
57async function openPane($: EngineInterface, open: { id: typeof PANE; title: string; rows: 12; columns: 48 }, s: Strings): Promise<void> {
58  await $.ui.open(open)
59  await update($, paneTick, n => n + 1)
60  await publish($, s)
61}
62
63// Whatever the pane drew counts as read once it closes; marking twice is harmless.
64async function markRead($: EngineInterface, s: Strings): Promise<void> {
65  await update($, seen, count => Math.max(count, shown))
66  await update($, paneTick, n => n + 1)
67  await publish($, s)
68}
69
70export const register: Register = (on, options) => {
71  const s = stringsFor(options?.language)
72  const open = { id: PANE, title: s.title, rows: 12, columns: 48 } as const
73  on('session.start', async ($, e, next) => {
74    await $.env.set('HOOK_NOTICES_SINK', '1')
75    await $.command.register({ name: 'hook-notices', description: "Show or hide this session's hook messages" })
76    lastSize = undefined
77    shown = 0
78    // A reload drops this mod's pane without telling its hooks; the line it published
79    // while the pane was up would keep the shared row from offering it again.
80    await publish($, s)
81    $.clock.every(1000, () => poll($, s))
82    return next(e)
83  })
84
85  on('command.run', { command: 'hook-notices' }, async $ => {
86    if (await isPlaced($)) {
87      await $.ui.close({ id: PANE })
88      await markRead($, s)
89      return { text: s.folded }
90    }
91    await openPane($, open, s)
92    return { text: s.open }
93  })
94
95  // prompt-band's line for this mod: opened here, inside the person's press, the pane is
96  // placed at any width (opened from any later event it would wait below 144 columns).
97  on('ui.press', { plugin: 'prompt-band', element: 'hook-notices' }, async ($, e, next) => {
98    await openPane($, open, s)
99    return next(e)
100  })
101
102  on('ui.close', async ($, e, next) => {
103    const result = await next(e)
104    // The engine's own close (`unload`: the mod unloaded or its drawing threw) is not
105    // sent to the opener's hooks; the check only keeps that close from marking anything.
106    if (e.id === PANE && e.origin.kind !== 'unload') await markRead($, s)
107    return result
108  })
109
110  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
111    if (e.props.hasSurvey || (await hasPromptBand($))) return next(e)
112    await read($, paneTick)
113    const list = await read($, notices)
114    if (list.length === 0 || (await isPlaced($))) return next(e)
115    const { Box, Button } = $.ui.resolve(e)
116    const below = await next(e)
117    // No hotkey on this row: a plain Button draws one as `h: ` ahead of the label.
118    // Three cells stay free at the right so the › lines up with the navigator line's ✕.
119    const width = Math.max(24, e.props.bodyColumns - 4)
120    return (
121      <Box flexDirection="column">
122        <Button key="open" plain dimColor label={fitRow(bandLabel(list, await read($, seen), s), s.action, width)} onPress={() => openPane($, open, s)} />
123        {below}
124      </Box>
125    )
126  })
127
128  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
129    const { Box, Text } = $.ui.resolve(e)
130    const list = await read($, notices)
131    shown = list.length
132    if (list.length === 0) return <Text dimColor>{s.empty}</Text>
133    return (
134      <Box flexDirection="column">
135        {list.slice().reverse().map((item, index) => {
136          const { title, details } = formatNotice(item.text)
137          const isAlert = item.level === 'alert'
138          return (
139            // Padding, not leading spaces, indents the body: a wrapped line keeps the indent.
140            <Box key={`notice-${index}`} flexDirection="column" marginTop={index > 0 ? 1 : 0}>
141              {/* The icon gets a fixed two cells: measured as its own Text, ⚠ lost its space. */}
142              <Box flexDirection="row">
143                <Box width={2} flexShrink={0}><Text bold color={isAlert ? 'warning' : undefined}>{isAlert ? '⚠' : '•'}</Text></Box>
144                <Box flexGrow={1} flexShrink={1}><Text bold color={isAlert ? 'warning' : undefined}>{title}</Text></Box>
145              </Box>
146              <Box flexDirection="column" paddingLeft={2}>
147                <Text dimColor>{item.event} · {new Date(item.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })}</Text>
148                {details.map((detail, detailIndex) => <Text key={`detail-${detailIndex}`}>{detail}</Text>)}
149              </Box>
150            </Box>
151          )
152        })}
153      </Box>
154    )
155  })
156}
157
hooks/notices.ts 77 lines
1import type { HookNotice } from '../types'
2import type { Strings } from './strings'
3
4export function parseNotices(text: string): HookNotice[] {
5  const notices: HookNotice[] = []
6  for (const line of text.split('\n')) {
7    if (!line.trim()) continue
8    try {
9      const value: unknown = JSON.parse(line)
10      if (typeof value !== 'object' || value === null) continue
11      const entry = value as Record<string, unknown>
12      if (typeof entry.ts !== 'string' || typeof entry.event !== 'string' || typeof entry.text !== 'string') continue
13      notices.push({ ts: entry.ts, event: entry.event, level: entry.level === 'alert' ? 'alert' : 'info', text: entry.text })
14    } catch {
15      // An incomplete or malformed line must not hide later notices.
16    }
17  }
18  return notices
19}
20
21export function headline(text: string, max = 80): string {
22  const first = (text.split(/\r?\n/).map(line => line.trim()).find(Boolean) ?? '').replace(/^(?:[·⚠]\s*|#+\s+)/u, '')
23  const chars = Array.from(first)
24  return chars.length > max ? `${chars.slice(0, Math.max(0, max - 1)).join('')}…` : first
25}
26
27/** The one-line summary: all read, or the unread count with the alert to look at first. */
28export function bandLabel(list: HookNotice[], seen: number, s: Strings): string {
29  const unseen = list.slice(seen)
30  if (unseen.length === 0) return s.bandRead(list.length)
31  const alerts = unseen.filter(item => item.level === 'alert').length
32  const lead = unseen.findLast(item => item.level === 'alert') ?? unseen.at(-1)
33  return s.bandUnread(unseen.length, alerts, headline(lead?.text ?? ''))
34}
35
36export function formatNotice(text: string): { title: string; details: string[] } {
37  const lines = text.split(/\r?\n/)
38    .filter(line => line.trim())
39    .map(line => line.trimEnd().trimStart()
40      .replace(/^[·⚠]\s*/u, '')
41      .replace(/^#+\s+/, '')
42      .replace(/\*\*/g, '')
43      .replace(/^[-*] /, '• '))
44    .filter(Boolean)
45  return { title: lines[0] ?? '', details: lines.slice(1) }
46}
47
48// East Asian wide and fullwidth ranges plus emoji: a terminal cell pair each.
49const WIDE = /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
50
51/** Terminal cells a string takes: wide characters count two. */
52export function cellWidth(text: string): number {
53  let width = 0
54  for (const char of text) width += WIDE.test(char) ? 2 : 1
55  return width
56}
57
58/**
59 * One row of exactly `width` cells: the text (cut with … when it must be), then the
60 * action at the right end. Drawn as one Button, the whole row is the tap target; a
61 * phone column is about 4px wide, too narrow to aim at a short label.
62 */
63export function fitRow(text: string, action: string, width: number): string {
64  const tail = ` ${action} ›`
65  const room = width - cellWidth(tail) - 1
66  let body = text
67  if (cellWidth(body) > room) {
68    let out = ''
69    for (const char of body) {
70      if (cellWidth(out + char) > room - 1) break
71      out += char
72    }
73    body = `${out}…`
74  }
75  return body + ' '.repeat(Math.max(1, width - cellWidth(body) - cellWidth(tail))) + tail
76}
77
hooks/strings.ts 35 lines
1export type Strings = {
2  title: string
3  action: string
4  folded: string
5  open: string
6  empty: string
7  bandRead: (count: number) => string
8  bandUnread: (count: number, alerts: number, headline: string) => string
9}
10
11export const STRINGS: Record<'en' | 'zh-TW', Strings> = {
12  en: {
13    title: 'Hook messages',
14    action: 'Open',
15    folded: 'Hook messages folded',
16    open: 'Hook messages open',
17    empty: 'No hook messages yet',
18    bandRead: count => `🪝 Hook messages · ${count} · read`,
19    bandUnread: (count, alerts, headline) => `🪝 Hook messages · ${count}${alerts > 0 ? ` · ⚠ ${alerts}` : ''} · ${headline}`,
20  },
21  'zh-TW': {
22    title: 'hook 訊息',
23    action: '展開',
24    folded: 'hook 訊息已收起',
25    open: 'hook 訊息已展開',
26    empty: '還沒有 hook 訊息',
27    bandRead: count => `🪝 hook 訊息 · ${count} 則 · 已讀`,
28    bandUnread: (count, alerts, headline) => `🪝 hook 訊息 · ${count} 則${alerts > 0 ? ` · ⚠ ${alerts}` : ''} · ${headline}`,
29  },
30}
31
32export function stringsFor(language: unknown): Strings {
33  return language === 'zh-TW' ? STRINGS['zh-TW'] : STRINGS.en
34}
35
types/index.d.ts 10 lines
1export type HookNotice = { ts: string; event: string; level: 'alert' | 'info'; text: string }
2/** One line above the prompt: its text, and the word for what pressing it does. */
3export type HookNoticesChip = { label: string; action: string }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'hook-notices': { notices: HookNotice[]; seen: number; paneTick: number; chip: HookNoticesChip | null }
8  }
9}
10