A toast when a work item changes outside this session, at most one every 30 s.

item-toasts shows a toast when a work item changes outside this session. Another session, a person at a shell or a sync can make that change. A change that a tool call of this session made draws no toast, because quiet-items and the transcript already show it.
handily-ab12 closed: Draw text mocks for the mods
handily-cd34 created: Write the beads reader
handily-cd34 updated: Write the beads reader (open -> in_progress)
3 work items changed: 2 closed, 1 created (handily-ab12, handily-cd34, +1)
Work items unavailable: basicly tracker items exited 2.
Work items are back: basicly · 14 open.
The approved mocks are in docs/mocks.md, section 4.
The mod reads the work items only through workitems. It keeps the snapshot version up to which it has read the diffs.
version moved, the mod calls $.workitems.refresh({ since }). It holds the diff for the next toast.refresh({ since }) the same way. A change from before the call is then a change made elsewhere.refresh({ since }) and drops the diff. The call made this change, or the change came while the call ran.When counted calls overlap, the mod drops the diff of every version from the start of the first call to the end of the last call. A change made elsewhere in that time draws no toast.
isTrackerWrite in hooks/match.ts decides which calls the mod treats as this session's own tracker writes. Every other call runs as if the mod were not there, so a change made elsewhere while it runs draws a toast.
| Tool | Counts when |
|---|---|
Bash | $.workitems.classify finds a tracker name: a write, an echoed write or an opaque command such as a loop |
Write, Edit | $.workitems.trackerFile names a tracker file at the workitems root |
--help, -h and --dry-run make a command a non-write, so it does not count.$.workitems.classify fails for a Bash call, when $.workitems.trackerFile fails for a Write or Edit call, and when workitems has no snapshot root yet for a Write or Edit call. A missed change made elsewhere is worse than a toast for this session's own change.quiet-items uses the same two rules of workitems, so both mods count the same calls. The command cases and their tests are in mods/workitems.Known limits:
bash close.sh, draws a toast for its own change.A Bash call with run_in_background, or a call that the person or the engine moves to the background, returns before its command ends. The mod keeps such a call open until one of these events:
prompt.submit with the origin task-notification). The mod reads the ids in its <tool-use-id> and <task-id> tags and compares them exactly with the tool_use_id of the call and the backgroundTaskId of its result. A notification without these tags closes nothing.Stop hook lists the background tasks of the session, and the backgroundTaskId of the call is not in the list.Until then, a change made elsewhere draws no toast. A new session start closes every open call. A reload of the mod forgets the open background calls, so a change that such a command makes after the reload draws a toast. A call that ends after its session ended stays closed: the mod counts a generation for each session and ignores a call from an older one.
While the snapshot is failed, workitems keeps the last good items. When it recovers, its diff holds every change from the whole failure. When a call of this session was open at any time during the failure, the mod drops that recovery diff. A change made elsewhere during that failure then draws no toast either.
<id> <created|updated|closed>: <title>. An update that moved the status adds (<old status> -> <new status>). The title is cut to 40 characters with ….N work items changed: <counts> (<id>, <id>, +N). The larger count comes first.created wins over closed, and closed wins over updated, as in workitems. An update shows the status from before the first change.| State | Toast |
|---|---|
ok after failed | Work items are back: <source> · <N> open., once |
failed after ok | Work items unavailable: <first sentence of the reason>., once |
failed that stays failed | None |
no-tracker, approval-needed, terminal-only, stale | None. The mod drops the waiting changes |
announced) and the time of its last toast (lastToastAt) in $.state, which outlives a reload of the mod. This is meant to keep a reload from repeating the failed toast or starting a new 30 s window. Tests cover it with a second session start only. A live reload is not tested.basicly source is terminal-only, so the mod shows nothing. A beads or beans source works as in a terminal.claude --plugin-dir mods
claude plugin test mods/item-toasts
item-toasts depends on workitems, so load the mods folder. The tests load a fake workitems provider, because claude plugin test loads only the mod under test.
The test kit has no module reload and no permission prompt. A test with a second session start stands in for a reload, and a call that waits before it runs stands in for a prompt. No test covers a live reload or a live permission prompt.
hooks/register.tsx 210 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2import { isTrackerWrite, type ToolUse, type TrackerRules } from './match'
3import { createToaster, type ToastHost, type Toaster } from './toasts'
4
5const TICK_MS = 2000
6const BACKGROUND_LIMIT_MS = 30 * 60 * 1000
7
8type CallResult = Awaited<ReturnType<EngineInterface['tool']['call']>>
9type BackgroundTask = { taskId: string | null; limit: Timer }
10
11type Toasts = {
12 toaster: Toaster | undefined
13 ticks: Timer | undefined
14 background: Map<string, BackgroundTask>
15 generation: number
16}
17
18const TASK_ID_TAG = /<task-id>([^<]*)<\/task-id>/g
19const TOOL_USE_ID_TAG = /<tool-use-id>([^<]*)<\/tool-use-id>/g
20const NOT_OWN = 'item-toasts: not counting the call as own;'
21
22function hostOf($: EngineInterface): ToastHost {
23 return {
24 snapshot: async () => (await $.state.get({ plugin: 'workitems', key: 'snapshot' })).value,
25 refresh: (since) => $.workitems.refresh({ since }),
26 lines: async (snapshot) => $.workitems.lines({ snapshot, now: await $.clock.now() }),
27 now: () => $.clock.now(),
28 toast: (text) => {
29 $.ui.toast(text)
30 },
31 log: (text) => {
32 $.ui.log(text)
33 },
34 announced: async () => (await $.state.get({ plugin: 'item-toasts', key: 'announced' })).value,
35 announce: async (health) => {
36 await $.state.set({ plugin: 'item-toasts', key: 'announced' }, health)
37 },
38 lastToastAt: async () =>
39 (await $.state.get({ plugin: 'item-toasts', key: 'lastToastAt' })).value,
40 noteToast: async (at) => {
41 await $.state.set({ plugin: 'item-toasts', key: 'lastToastAt' }, at)
42 },
43 }
44}
45
46function stopToasts(toasts: Toasts): void {
47 toasts.generation += 1
48 toasts.ticks?.cancel()
49 toasts.ticks = undefined
50 toasts.toaster = undefined
51 for (const task of toasts.background.values()) task.limit.cancel()
52 toasts.background.clear()
53}
54
55function startToasts($: EngineInterface, toasts: Toasts): Toaster {
56 stopToasts(toasts)
57 const toaster = createToaster(hostOf($))
58 toasts.toaster = toaster
59 toasts.ticks = $.clock.every(TICK_MS, () => {
60 toaster.tick().catch((error: unknown) => {
61 $.ui.log(`item-toasts: the tick failed: ${String(error)}`)
62 })
63 })
64 return toaster
65}
66
67function rulesOf($: EngineInterface): TrackerRules {
68 return {
69 classify: (command) => $.workitems.classify(command),
70 trackerFile: (args) => $.workitems.trackerFile(args),
71 }
72}
73
74async function isOwnCall($: EngineInterface, use: ToolUse): Promise<boolean> {
75 try {
76 return await isTrackerWrite(rulesOf($), use)
77 } catch (error) {
78 const check = use.tool === 'Bash' ? 'the command check' : 'the tracker file check'
79 $.ui.log(`${NOT_OWN} ${check} failed: ${String(error)}`)
80 return false
81 }
82}
83
84function backgroundTaskIdOf(
85 isLaunchedInBackground: boolean,
86 result: CallResult,
87): { taskId: string | null } | null {
88 const output: unknown = result.result
89 const taskId =
90 typeof output === 'object' &&
91 output !== null &&
92 'backgroundTaskId' in output &&
93 typeof output.backgroundTaskId === 'string'
94 ? output.backgroundTaskId
95 : null
96 if (taskId === null && !isLaunchedInBackground) return null
97 return { taskId }
98}
99
100async function endBackground(
101 toasts: Toasts,
102 hasEnded: (toolUseId: string, task: BackgroundTask) => boolean,
103): Promise<void> {
104 for (const [toolUseId, task] of [...toasts.background]) {
105 if (!hasEnded(toolUseId, task)) continue
106 task.limit.cancel()
107 toasts.background.delete(toolUseId)
108 await toasts.toaster?.leaveCall()
109 }
110}
111
112function keepBackgroundOpen(
113 $: EngineInterface,
114 toasts: Toasts,
115 toolUseId: string,
116 taskId: string | null,
117): void {
118 const limit = $.clock.after(BACKGROUND_LIMIT_MS, () => {
119 endBackground(toasts, (openId) => openId === toolUseId).catch((error: unknown) => {
120 $.ui.log(`item-toasts: closing a background call failed: ${String(error)}`)
121 })
122 })
123 toasts.background.set(toolUseId, { taskId, limit })
124}
125
126function idsIn(text: string, tag: RegExp): ReadonlySet<string> {
127 return new Set(Array.from(text.matchAll(tag), (match) => (match[1] ?? '').trim()))
128}
129
130function isNamedIn(text: string, toolUseId: string, task: BackgroundTask): boolean {
131 if (idsIn(text, TOOL_USE_ID_TAG).has(toolUseId)) return true
132 return task.taskId !== null && idsIn(text, TASK_ID_TAG).has(task.taskId)
133}
134
135export const register: Register = (on) => {
136 const toasts: Toasts = {
137 toaster: undefined,
138 ticks: undefined,
139 background: new Map(),
140 generation: 0,
141 }
142
143 on('session.start', async ($, e, next) => {
144 const started = await next(e)
145 await startToasts($, toasts).tick()
146 await $.state.set({ plugin: 'item-toasts', key: 'ready' }, { root: $.plugin.root })
147 return started
148 })
149
150 on('session.end', (_$, e, next) => {
151 stopToasts(toasts)
152 return next(e)
153 })
154
155 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
156 if (!(await isOwnCall($, { tool: 'Bash', command: e.command }))) return next(e)
157 const toaster = toasts.toaster ?? startToasts($, toasts)
158 const generation = toasts.generation
159 await toaster.enterCall()
160 let result: CallResult
161 try {
162 result = await next(e)
163 } catch (error) {
164 await toaster.leaveCall()
165 throw error
166 }
167 if (toasts.generation !== generation) return result
168 const background = backgroundTaskIdOf(e.run_in_background === true, result)
169 if (background === null) await toaster.leaveCall()
170 else keepBackgroundOpen($, toasts, e.tool_use_id, background.taskId)
171 return result
172 })
173
174 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
175 const { value: snapshot } = await $.state.get({ plugin: 'workitems', key: 'snapshot' })
176 if (snapshot === undefined) {
177 $.ui.log(`${NOT_OWN} workitems has no root yet`)
178 return next(e)
179 }
180 const use: ToolUse = { tool: e.tool, filePath: e.file_path, root: snapshot.root }
181 if (!(await isOwnCall($, use))) return next(e)
182 const toaster = toasts.toaster ?? startToasts($, toasts)
183 await toaster.enterCall()
184 try {
185 return await next(e)
186 } finally {
187 await toaster.leaveCall()
188 }
189 })
190
191 on('prompt.submit', async (_$, e, next) => {
192 if (e.origin.kind === 'task-notification') {
193 await endBackground(toasts, (toolUseId, task) => isNamedIn(e.text, toolUseId, task))
194 }
195 return next(e)
196 })
197
198 on('classic.Stop', async (_$, e, next) => {
199 const running = e.background_tasks
200 if (running !== undefined) {
201 const runningIds = new Set(running.map((task) => task.id))
202 await endBackground(
203 toasts,
204 (_toolUseId, task) => task.taskId !== null && !runningIds.has(task.taskId),
205 )
206 }
207 return next(e)
208 })
209}
210hooks/match.ts 12 lines1import type { EngineInterface } from 'claude-code'
2
3export type TrackerRules = Pick<EngineInterface['workitems'], 'classify' | 'trackerFile'>
4
5export type ToolUse =
6 { tool: 'Bash'; command: string } | { tool: 'Write' | 'Edit'; filePath: string; root: string }
7
8export async function isTrackerWrite(rules: TrackerRules, use: ToolUse): Promise<boolean> {
9 if (use.tool === 'Bash') return (await rules.classify(use.command)).kind !== 'none'
10 return (await rules.trackerFile({ path: use.filePath, root: use.root })) !== null
11}
12hooks/toasts.ts 232 lines1import type { EngineInterface, PluginState } from 'claude-code'
2import type { ItemToastsHealth as Health } from '../types'
3
4type Snapshot = PluginState['workitems']['snapshot']
5type Refreshed = Awaited<ReturnType<EngineInterface['workitems']['refresh']>>
6type Item = Refreshed['created'][number]
7type Line = Awaited<ReturnType<EngineInterface['workitems']['lines']>>[number]
8type ChangeKind = 'created' | 'updated' | 'closed'
9
10export const TITLE_LENGTH = 40
11export const QUIET_WINDOW_MS = 30_000
12
13const NAMED_IDS = 2
14const KINDS_IN_DIFF_ORDER: readonly ChangeKind[] = ['created', 'updated', 'closed']
15const KINDS_BY_STRENGTH: readonly ChangeKind[] = ['updated', 'closed', 'created']
16const HEADER_AGE_PART = ' \u00b7 read '
17
18export type Change = {
19 kind: ChangeKind
20 id: string
21 title: string
22 from: string | null
23 to: string
24}
25
26export type ToastHost = {
27 snapshot: () => Promise<Snapshot | undefined>
28 refresh: (since: number) => Promise<Refreshed>
29 lines: (snapshot: Snapshot) => Promise<readonly Line[]>
30 now: () => Promise<number>
31 toast: (text: string) => void
32 log: (text: string) => void
33 announced: () => Promise<Health | undefined>
34 announce: (health: Health) => Promise<void>
35 lastToastAt: () => Promise<number | undefined>
36 noteToast: (at: number) => Promise<void>
37}
38
39export type Toaster = {
40 tick: () => Promise<void>
41 enterCall: () => Promise<void>
42 leaveCall: () => Promise<void>
43}
44
45export function cutTitle(title: string, length: number): string {
46 const graphemes = Array.from(new Intl.Segmenter().segment(title), (part) => part.segment)
47 if (graphemes.length <= length) return title
48 return `${graphemes.slice(0, length).join('').trimEnd()}\u2026`
49}
50
51function statusMove(change: Change): string {
52 if (change.kind !== 'updated' || change.from === null || change.from === change.to) return ''
53 return ` (${change.from} -> ${change.to})`
54}
55
56export function changeText(change: Change): string {
57 return `${change.id} ${change.kind}: ${cutTitle(change.title, TITLE_LENGTH)}${statusMove(change)}`
58}
59
60function countsText(changes: readonly Change[]): string {
61 const counts = KINDS_IN_DIFF_ORDER.map((kind) => ({
62 kind,
63 count: changes.filter((change) => change.kind === kind).length,
64 }))
65 return counts
66 .filter(({ count }) => count > 0)
67 .sort((a, b) => b.count - a.count)
68 .map(({ kind, count }) => `${String(count)} ${kind}`)
69 .join(', ')
70}
71
72function idsText(changes: readonly Change[]): string {
73 const named = changes.slice(0, NAMED_IDS).map((change) => change.id)
74 const rest = changes.length - named.length
75 return rest > 0 ? [...named, `+${String(rest)}`].join(', ') : named.join(', ')
76}
77
78export function changesText(changes: readonly Change[]): string {
79 const [only] = changes
80 if (changes.length === 1 && only !== undefined) return changeText(only)
81 return `${String(changes.length)} work items changed: ${countsText(changes)} (${idsText(changes)})`
82}
83
84function healthOf(snapshot: Snapshot): Health | null {
85 if (snapshot.state === 'ok') return 'ok'
86 if (snapshot.state === 'failed') return 'failed'
87 return null
88}
89
90function firstSentence(text: string): string {
91 const [first = text] = text.split('. ')
92 return `${first.replace(/\.$/, '')}.`
93}
94
95export function healthText(lines: readonly Line[], health: Health): string {
96 if (health === 'failed') {
97 const failed = lines.find((line) => line.kind === 'failed')
98 if (!failed) throw new Error('item-toasts: workitems drew no failed line for a failed snapshot')
99 return firstSentence(failed.text)
100 }
101 const header = lines.find((line) => line.kind === 'header')
102 if (!header) throw new Error('item-toasts: workitems drew no header line for an ok snapshot')
103 const [label = header.text] = header.text.split(HEADER_AGE_PART)
104 return `Work items are back: ${label}.`
105}
106
107function strongerKind(held: ChangeKind, next: ChangeKind): ChangeKind {
108 return KINDS_BY_STRENGTH.indexOf(held) > KINDS_BY_STRENGTH.indexOf(next) ? held : next
109}
110
111export function createToaster(host: ToastHost): Toaster {
112 let seen: number | undefined
113 let runningCalls = 0
114 let isFailed = false
115 let hasOwnCallDuringFailure = false
116 const knownStatus = new Map<string, string>()
117 const pending = new Map<string, Change>()
118 let queue: Promise<void> = Promise.resolve()
119
120 function inTurn(step: () => Promise<void>): Promise<void> {
121 const run = queue.then(step).catch((error: unknown) => {
122 host.log(`item-toasts: ${String(error)}`)
123 })
124 queue = run
125 return run
126 }
127
128 function remember(items: readonly Item[]): void {
129 for (const item of items) knownStatus.set(item.key, item.rawStatus)
130 }
131
132 function hold(refreshed: Refreshed): void {
133 for (const kind of KINDS_IN_DIFF_ORDER) {
134 for (const item of refreshed[kind]) {
135 const change: Change = {
136 kind,
137 id: item.id,
138 title: item.title,
139 from: knownStatus.get(item.key) ?? null,
140 to: item.rawStatus,
141 }
142 const held = pending.get(item.key)
143 pending.set(
144 item.key,
145 held ? { ...change, kind: strongerKind(held.kind, kind), from: held.from } : change,
146 )
147 }
148 }
149 }
150
151 async function catchUp(isElsewhere: boolean, mustRead: boolean): Promise<void> {
152 const snapshot = await host.snapshot()
153 if (snapshot === undefined) return
154 if (seen === undefined) {
155 seen = snapshot.version
156 isFailed = snapshot.state === 'failed'
157 remember(snapshot.items)
158 }
159 if (!mustRead && snapshot.version === seen) return
160 let refreshed: Refreshed
161 try {
162 refreshed = await host.refresh(seen)
163 } catch (error) {
164 seen = undefined
165 throw error
166 }
167 seen = refreshed.version
168 const wasFailed = isFailed
169 isFailed = (await host.snapshot())?.state === 'failed'
170 const hasRecovered = wasFailed && !isFailed
171 const isOwnRecovery = hasRecovered && hasOwnCallDuringFailure
172 if (hasRecovered) hasOwnCallDuringFailure = false
173 if (isElsewhere && !isOwnRecovery) hold(refreshed)
174 remember([...refreshed.created, ...refreshed.updated, ...refreshed.closed])
175 }
176
177 function noteOwnCall(): void {
178 if (isFailed) hasOwnCallDuringFailure = true
179 }
180
181 async function nextText(snapshot: Snapshot, health: Health): Promise<string | null> {
182 if (health !== ((await host.announced()) ?? 'ok')) {
183 const text = healthText(await host.lines(snapshot), health)
184 await host.announce(health)
185 return text
186 }
187 if (pending.size === 0) return null
188 const text = changesText([...pending.values()])
189 pending.clear()
190 return text
191 }
192
193 async function flush(): Promise<void> {
194 const snapshot = await host.snapshot()
195 if (snapshot === undefined) return
196 const health = healthOf(snapshot)
197 if (health === null) {
198 pending.clear()
199 return
200 }
201 const now = await host.now()
202 const lastToastAt = await host.lastToastAt()
203 if (lastToastAt !== undefined && now - lastToastAt < QUIET_WINDOW_MS) return
204 const text = await nextText(snapshot, health)
205 if (text === null) return
206 host.toast(text)
207 await host.noteToast(now)
208 }
209
210 return {
211 tick: () =>
212 inTurn(async () => {
213 if (runningCalls === 0) await catchUp(true, false)
214 await flush()
215 }),
216 enterCall: () =>
217 inTurn(async () => {
218 runningCalls += 1
219 if (runningCalls > 1) return
220 await catchUp(true, true)
221 await flush()
222 }),
223 leaveCall: () =>
224 inTurn(async () => {
225 runningCalls -= 1
226 if (runningCalls > 0) return
227 await catchUp(false, true)
228 noteOwnCall()
229 }),
230 }
231}
232types/index.d.ts 12 lines1export type ItemToastsHealth = 'ok' | 'failed'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'item-toasts': {
6 announced: ItemToastsHealth
7 lastToastAt: number
8 ready: { root: string }
9 }
10 }
11}
12