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

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.
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:
$HOOK_NOTICES_DIR/<session_id>.jsonl, default ~/.claude/data/hook-notices/<session_id>.jsonl{"ts": "2026-10-09T08:47:58Z", "event": "Stop", "level": "info", "text": "…"} (level is "info" or "alert"; anything else reads as info)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.
language: en (default) or zh-TW, from /config or settings:
"pluginConfigs": { "hook-notices@claude-code-mods": { "options": { "language": "zh-TW" } } }
Installed together with prompt-band, this line and turn-nav's share one row.
hooks/register.tsx 157 lines1import { 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}
157hooks/notices.ts 77 lines1import 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}
77hooks/strings.ts 35 lines1export 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}
35types/index.d.ts 10 lines1export 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