Tells you when an atelier finishes: flux7-studio renders, as toasts and a line under the prompt

Tells you, inside a Claude Code session, when an atelier has finished its work. Studio only for now.
Every 3 s it reads GET localhost:8700/outputs (flux7-studio) and GET localhost:8188/queue (ComfyUI):
studio: image kf_00042_.png is ready, or one toast for a batch;studio: rendering, 2 queued while ComfyUI works, then studio: last kf_00042_.png at 12:31;/bell prints that status; /bell test rings a sample toast, to hear avatar7 announce it without waiting for a render.The first answer only seeds what is already there: a session never rings for renders made before it started. Studio or ComfyUI down: it stays quiet.
With avatar7 loaded, the avatar announces each toast in its own voice (avatar7 hooks ui.toast and keeps those whose next.origin.plugin is atelier-bell). Neither mod depends on the other.
hooks/register.ts 95 lines1import type { Engine, Register } from 'claude-code'
2
3// flux7-studio and the ComfyUI behind it, both local.
4const STUDIO = 'http://localhost:8700'
5const COMFY = 'http://localhost:8188'
6const POLL_MS = 3_000
7
8type Output = { name: string; kind: string; mtime: number }
9type Queue = { queue_running: unknown[]; queue_pending: unknown[] }
10
11const clock = (seconds: number): string => {
12 const d = new Date(seconds * 1000)
13 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
14}
15
16// For avatar7, when loaded: a render ready is calm news.
17async function announce($: Engine): Promise<void> {
18 await $.state.set({ plugin: 'atelier-bell', key: 'announce' }, { mood: 'watch', event: 'an atelier finished its work' })
19}
20
21export const register: Register = on => {
22 // Outputs already there; null until the first answer, which only seeds it,
23 // so a session never rings for renders made before it started.
24 let seen: Set<string> | null = null
25 let status = ''
26 let last = ''
27
28 on('session.start', async ($, e, next) => {
29 await announce($)
30 await $.command.register({ name: 'bell', description: 'What the ateliers are doing now (test: a sample ring)' })
31 // A status line pinned by an earlier version stays until cleared.
32 $.ui.status(undefined)
33
34 $.clock.every(POLL_MS, async () => {
35 try {
36 const [out, queue] = await Promise.all([$.http.fetch(`${STUDIO}/outputs`), $.http.fetch(`${COMFY}/queue`)])
37 if (!out.ok) return
38 const outputs = JSON.parse(out.text) as Output[]
39
40 if (seen === null) {
41 seen = new Set(outputs.map(o => o.name))
42 } else {
43 const fresh = outputs.filter(o => !seen?.has(o.name)).sort((a, b) => a.mtime - b.mtime)
44 // A purge drops names; the set follows what is there.
45 seen = new Set(outputs.map(o => o.name))
46 const newest = fresh.at(-1)
47 if (newest !== undefined) {
48 last = `${newest.name} at ${clock(newest.mtime)}`
49 $.ui.toast(
50 fresh.length === 1
51 ? `studio: ${newest.kind} ${newest.name} is ready`
52 : `studio: ${fresh.length} renders are ready, the last ${newest.name}`,
53 )
54 }
55 }
56
57 const q = queue.ok ? (JSON.parse(queue.text) as Queue) : { queue_running: [], queue_pending: [] }
58 const running = q.queue_running.length
59 const waiting = q.queue_pending.length
60 const now =
61 running > 0
62 ? `studio: rendering${waiting > 0 ? `, ${waiting} queued` : ''}`
63 : last !== ''
64 ? `studio: last ${last}`
65 : ''
66 if (now !== status) {
67 status = now
68 $.ui.invalidate('ui.render')
69 }
70 } catch {
71 // studio or ComfyUI down: stay quiet, try again at the next poll.
72 }
73 })
74
75 return next(e)
76 })
77
78 // The status, dim at the end of the hint line under the prompt: a pinned
79 // status line would come with the engine's warning sign; the toasts ring.
80 on('ui.render', { component: 'PromptHint' }, async ($, e, next) =>
81 status === '' ? next(e) : next({ ...e, props: { ...e.props, tail: `${e.props.tail ?? ''} · ${status}` } }),
82 )
83
84 // `/bell test`: a sample ring, so the voice avatar7 lends the bell can be
85 // heard without waiting for a render.
86 on('command.run', { command: 'bell' }, async ($, e) => {
87 if (e.args.trim() === 'test') {
88 await announce($)
89 $.ui.toast('test ring: studio: image sample.png is ready, a sample, nothing was rendered')
90 return { text: 'test ring sent.' }
91 }
92 return { text: status === '' ? 'studio: nothing rendered since this session started.' : status }
93 })
94}
95types/index.d.ts 11 lines1// What this mod asks of avatar7 when it toasts, if avatar7 is loaded: the
2// face (calm, or amber for a warning) and what happened. avatar7 hears the
3// write; nothing here depends on it.
4export type Announce = { mood: 'watch' | 'error'; event: string }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'atelier-bell': { announce: Announce }
9 }
10}
11