SLOPSHOPPER

item-toasts

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

newguardtoastprompttimer
v0.3.0MITupdated 2026-10-09niksavis/handily/mods/item-toasts
A shopper browsing a rack in a slop shop
README

item-toasts

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.

How it finds a change

The mod reads the work items only through workitems. It keeps the snapshot version up to which it has read the diffs.

  1. Every 2 s, the mod reads the snapshot. When its version moved, the mod calls $.workitems.refresh({ since }). It holds the diff for the next toast.
  2. Before a counted call runs (see "Which calls count"), the mod calls refresh({ since }) the same way. A change from before the call is then a change made elsewhere.
  3. While a counted call runs, the mod reads no diff.
  4. After the last running counted call ends, the mod calls 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.

Which calls count

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.

ToolCounts 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.
  • When the mod cannot decide, it does not count the call, and it logs why. This happens when $.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:

  • A change made elsewhere while a counted call runs draws no toast.
  • A script that writes the tracker without a tracker name in its command, such as bash close.sh, draws a toast for its own change.

A call that runs on in the background

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:

  • The task notification of the call arrives (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.
  • A Stop hook lists the background tasks of the session, and the backgroundTaskId of the call is not in the list.
  • 30 minutes pass. The Stop hook may not run, for example when a Team organisation's security plugin bypasses the user-tier settings hooks. This limit keeps the toasts from stopping for good.

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.

A change during a failure

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.

The toast

  • One change draws <id> <created|updated|closed>: <title>. An update that moved the status adds (<old status> -> <new status>). The title is cut to 40 characters with ….
  • The mod shows at most one toast every 30 s. A change inside that time waits, and every waiting change goes into the next toast: N work items changed: <counts> (<id>, <id>, +N). The larger count comes first.
  • When an item changes more than once before its toast, created wins over closed, and closed wins over updated, as in workitems. An update shows the status from before the first change.

The snapshot state

StateToast
ok after failedWork items are back: <source> · <N> open., once
failed after okWork items unavailable: <first sentence of the reason>., once
failed that stays failedNone
no-tracker, approval-needed, terminal-only, staleNone. The mod drops the waiting changes
  • A state toast also waits for the 30 s limit. A failure that recovers before its toast shows draws no toast.
  • The mod keeps the state it last toasted (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.
  • On desktop, a basicly source is terminal-only, so the mod shows nothing. A beads or beans source works as in a terminal.

Develop

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.

Source 4 files
hooks/register.tsx 210 lines
1import 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}
210
hooks/match.ts 12 lines
1import 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}
12
hooks/toasts.ts 232 lines
1import 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}
232
types/index.d.ts 12 lines
1export 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