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/handily-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/handily-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 212 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 () =>
25 (await $.state.get({ plugin: 'handily-workitems', key: 'snapshot' })).value,
26 refresh: (since) => $.workitems.refresh({ since }),
27 lines: async (snapshot) => $.workitems.lines({ snapshot, now: await $.clock.now() }),
28 now: () => $.clock.now(),
29 toast: (text) => {
30 $.ui.toast(text)
31 },
32 log: (text) => {
33 $.ui.log(text)
34 },
35 announced: async () =>
36 (await $.state.get({ plugin: 'handily-item-toasts', key: 'announced' })).value,
37 announce: async (health) => {
38 await $.state.set({ plugin: 'handily-item-toasts', key: 'announced' }, health)
39 },
40 lastToastAt: async () =>
41 (await $.state.get({ plugin: 'handily-item-toasts', key: 'lastToastAt' })).value,
42 noteToast: async (at) => {
43 await $.state.set({ plugin: 'handily-item-toasts', key: 'lastToastAt' }, at)
44 },
45 }
46}
47
48function stopToasts(toasts: Toasts): void {
49 toasts.generation += 1
50 toasts.ticks?.cancel()
51 toasts.ticks = undefined
52 toasts.toaster = undefined
53 for (const task of toasts.background.values()) task.limit.cancel()
54 toasts.background.clear()
55}
56
57function startToasts($: EngineInterface, toasts: Toasts): Toaster {
58 stopToasts(toasts)
59 const toaster = createToaster(hostOf($))
60 toasts.toaster = toaster
61 toasts.ticks = $.clock.every(TICK_MS, () => {
62 toaster.tick().catch((error: unknown) => {
63 $.ui.log(`item-toasts: the tick failed: ${String(error)}`)
64 })
65 })
66 return toaster
67}
68
69function rulesOf($: EngineInterface): TrackerRules {
70 return {
71 classify: (command) => $.workitems.classify(command),
72 trackerFile: (args) => $.workitems.trackerFile(args),
73 }
74}
75
76async function isOwnCall($: EngineInterface, use: ToolUse): Promise<boolean> {
77 try {
78 return await isTrackerWrite(rulesOf($), use)
79 } catch (error) {
80 const check = use.tool === 'Bash' ? 'the command check' : 'the tracker file check'
81 $.ui.log(`${NOT_OWN} ${check} failed: ${String(error)}`)
82 return false
83 }
84}
85
86function backgroundTaskIdOf(
87 isLaunchedInBackground: boolean,
88 result: CallResult,
89): { taskId: string | null } | null {
90 const output: unknown = result.result
91 const taskId =
92 typeof output === 'object' &&
93 output !== null &&
94 'backgroundTaskId' in output &&
95 typeof output.backgroundTaskId === 'string'
96 ? output.backgroundTaskId
97 : null
98 if (taskId === null && !isLaunchedInBackground) return null
99 return { taskId }
100}
101
102async function endBackground(
103 toasts: Toasts,
104 hasEnded: (toolUseId: string, task: BackgroundTask) => boolean,
105): Promise<void> {
106 for (const [toolUseId, task] of [...toasts.background]) {
107 if (!hasEnded(toolUseId, task)) continue
108 task.limit.cancel()
109 toasts.background.delete(toolUseId)
110 await toasts.toaster?.leaveCall()
111 }
112}
113
114function keepBackgroundOpen(
115 $: EngineInterface,
116 toasts: Toasts,
117 toolUseId: string,
118 taskId: string | null,
119): void {
120 const limit = $.clock.after(BACKGROUND_LIMIT_MS, () => {
121 endBackground(toasts, (openId) => openId === toolUseId).catch((error: unknown) => {
122 $.ui.log(`item-toasts: closing a background call failed: ${String(error)}`)
123 })
124 })
125 toasts.background.set(toolUseId, { taskId, limit })
126}
127
128function idsIn(text: string, tag: RegExp): ReadonlySet<string> {
129 return new Set(Array.from(text.matchAll(tag), (match) => (match[1] ?? '').trim()))
130}
131
132function isNamedIn(text: string, toolUseId: string, task: BackgroundTask): boolean {
133 if (idsIn(text, TOOL_USE_ID_TAG).has(toolUseId)) return true
134 return task.taskId !== null && idsIn(text, TASK_ID_TAG).has(task.taskId)
135}
136
137export const register: Register = (on) => {
138 const toasts: Toasts = {
139 toaster: undefined,
140 ticks: undefined,
141 background: new Map(),
142 generation: 0,
143 }
144
145 on('session.start', async ($, e, next) => {
146 const started = await next(e)
147 await startToasts($, toasts).tick()
148 await $.state.set({ plugin: 'handily-item-toasts', key: 'ready' }, { root: $.plugin.root })
149 return started
150 })
151
152 on('session.end', (_$, e, next) => {
153 stopToasts(toasts)
154 return next(e)
155 })
156
157 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
158 if (!(await isOwnCall($, { tool: 'Bash', command: e.command }))) return next(e)
159 const toaster = toasts.toaster ?? startToasts($, toasts)
160 const generation = toasts.generation
161 await toaster.enterCall()
162 let result: CallResult
163 try {
164 result = await next(e)
165 } catch (error) {
166 await toaster.leaveCall()
167 throw error
168 }
169 if (toasts.generation !== generation) return result
170 const background = backgroundTaskIdOf(e.run_in_background === true, result)
171 if (background === null) await toaster.leaveCall()
172 else keepBackgroundOpen($, toasts, e.tool_use_id, background.taskId)
173 return result
174 })
175
176 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
177 const { value: snapshot } = await $.state.get({ plugin: 'handily-workitems', key: 'snapshot' })
178 if (snapshot === undefined) {
179 $.ui.log(`${NOT_OWN} workitems has no root yet`)
180 return next(e)
181 }
182 const use: ToolUse = { tool: e.tool, filePath: e.file_path, root: snapshot.root }
183 if (!(await isOwnCall($, use))) return next(e)
184 const toaster = toasts.toaster ?? startToasts($, toasts)
185 await toaster.enterCall()
186 try {
187 return await next(e)
188 } finally {
189 await toaster.leaveCall()
190 }
191 })
192
193 on('prompt.submit', async (_$, e, next) => {
194 if (e.origin.kind === 'task-notification') {
195 await endBackground(toasts, (toolUseId, task) => isNamedIn(e.text, toolUseId, task))
196 }
197 return next(e)
198 })
199
200 on('classic.Stop', async (_$, e, next) => {
201 const running = e.background_tasks
202 if (running !== undefined) {
203 const runningIds = new Set(running.map((task) => task.id))
204 await endBackground(
205 toasts,
206 (_toolUseId, task) => task.taskId !== null && !runningIds.has(task.taskId),
207 )
208 }
209 return next(e)
210 })
211}
212hooks/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['handily-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 'handily-item-toasts': {
6 announced: ItemToastsHealth
7 lastToastAt: number
8 ready: { root: string }
9 }
10 }
11}
12