Follows what you and Claude are doing: says the step in the spinner line, whose move it is above the prompt, and chimes when it's yours.

A Claude Code mod that follows what you and Claude are doing. Built for an ADD brain: nothing to set up and nothing to manage. It says what Claude is doing right now, whose move it is, and calls you back when it's yours.
Ember lives in the chat itself. In the terminal:
✳ Reading login.ts… (4s · ↓ 146 tokens)
● Working 3 steps
and once Claude is done:
◆ Your move 2m You asked: fix the flaky login test
● Working, ● 2 agents working, ▲ Needs your OK: Bash, ◆ Your move. When it's your move it also shows what you last asked, for when you come back to it. Nothing on it moves or counts seconds.The desktop app gets the same band and transcript line; there the band also says the step, since the spinner row already does its own narrating.
Commands, all optional:
/ember says it's following and whether sound is on./ember mute and /ember unmute switch the chimes. The setting is kept between sessions./ember pane opens a pane. In the desktop app it shows an animated flame that sleeps until you prompt, wakes while Claude works, bounces when Claude is blocked on you, and grows through a long stretch of work.<table align="center"> <tr> <td align="center"><img src="docs/rest.svg" width="120" alt="Ember asleep"><br><sub>asleep</sub></td> <td align="center"><img src="docs/work.svg" width="120" alt="Ember while Claude works"><br><sub>working</sub></td> <td align="center"><img src="docs/blocked.svg" width="120" alt="Ember when Claude is waiting on you"><br><sub>needs you</sub></td> <td align="center"><img src="docs/done.svg" width="120" alt="Ember when it is your move"><br><sub>your move</sub></td> </tr> </table>
git clone https://github.com/nickdemari/ember.git
claude --plugin-dir ./ember
To load it in every terminal session, name the folder in your shell profile:
export CLAUDE_CODE_PLUGIN_DIRS="$HOME/path/to/ember"
In the desktop app's Code tab there is no flag to pass. Ask Claude to load its plugin-authoring skill and copy this folder into the session's mods folder, then pick Enable for this session when asked.
Ember is written against the function-hooks plugin API of Claude Code 2.1.286. That API is early access: it can change between releases, and whether hooks modules load at all depends on your version and account.
npx -p typescript tsc -p .
claude plugin validate .
claude plugin test .
tsc needs .claude-plugin/types/, which the engine writes the first time it loads the mod.
hooks/register.tsx: every hook, the band and the pane.hooks/creature.ts: the flame as one SMIL-animated SVG per mood, and the caption beside it.hooks/narrate.ts: tool calls in plain words.types/index.d.ts: the state contract.fx/: the chime.MIT
hooks/register.tsx 606 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { EmberAgent, EmberGesture, EmberLive, EmberMood, EmberPhase } from '../types'
5import { caption, portrait } from './creature'
6import { clip, narrate, span } from './narrate'
7
8const PANE = 'ember'
9const CHIME = 'fx/chime.wav'
10
11const WORK = '#E8912D'
12const YOU = '#FF5D73'
13const READY = '#3FB68B'
14
15// Work shorter than this ended while he was still looking at it: no chime.
16const CHIME_AFTER_MS = 20_000
17// How long a question or permission prompt may sit before the chime calls him back.
18const KNOCK_AFTER_MS = 10_000
19// The engine says when a permission prompt opens, never when it is answered:
20// past this the creature stops insisting, so an approved long command is not
21// drawn as waiting on him for its whole run.
22const BLOCK_DECAY_MS = 40_000
23// How long his move may stand before one chime asks whether he is still there.
24const NUDGE_AFTER_MS = 5 * 60_000
25// Nothing drawn counts seconds, so the clock the drawings read moves this often
26// and no more: a row redrawn every second reads as blinking.
27const HEARTBEAT_MS = 15_000
28// The main loop takes an agent's report up within a moment of its end: wait this long
29// before calling it his move, so a turn about to start is not chimed over.
30const REPORT_MS = 2500
31// Shown once it is his move: what he last asked, cut to this.
32const ASK_SHOWN = 60
33
34// Prompts nobody typed: they start turns, but they are not what he asked for.
35const MACHINE = new Set([
36 'task-notification',
37 'scheduled-trigger',
38 'peer',
39 'peer-send-message',
40 'projects-relay',
41 'channel',
42 'coordinator',
43 'observer',
44 'observer-activity',
45 'auto-continuation',
46 'plugin',
47])
48
49// An agent the engine lists under one of these has stopped, however it ended.
50const ENDED = new Set(['completed', 'failed', 'killed', 'stopped', 'cancelled'])
51
52const PHASES: readonly string[] = ['rest', 'work', 'blocked', 'done']
53
54const feel = (phase: EmberPhase, gesture: EmberGesture): EmberMood => ({ phase, gesture })
55
56const mood = atom({ plugin: 'ember', key: 'mood' } as const, feel('rest', 'think'))
57const live = atom({ plugin: 'ember', key: 'live' } as const, { label: '', since: 0, tools: 0 })
58const agents = atom({ plugin: 'ember', key: 'agents' } as const, [])
59const ask = atom({ plugin: 'ember', key: 'ask' } as const, '')
60const isMuted = atom({ plugin: 'ember', key: 'isMuted' } as const, false)
61const isBusy = atom({ plugin: 'ember', key: 'isBusy' } as const, false)
62const now = atom({ plugin: 'ember', key: 'now' } as const, 0)
63
64/** The flame grows through a long stretch of work. */
65const heatOf = (current: EmberMood, step: EmberLive): number => {
66 if (current.phase === 'rest') {
67 return 0
68 }
69
70 if (step.tools < 5) {
71 return 1
72 }
73
74 return step.tools < 15 ? 2 : 3
75}
76
77const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}`
78
79/** What is being done right now, in words: the main loop's own step, else what its agents are on. */
80const saying = (step: EmberLive, crew: readonly EmberAgent[], isTurn: boolean): string => {
81 if (isTurn && step.label !== '' && step.label !== 'Thinking') {
82 return step.label
83 }
84
85 const last = crew.at(-1)
86
87 if (last !== undefined) {
88 return clip(`${crew.length === 1 ? 'Agent' : `${crew.length} agents`}: ${last.label}`, 72)
89 }
90
91 return isTurn ? 'Thinking' : ''
92}
93
94/** The state in a glyph and a few words, its color, and the detail beside it: one voice for the band and the pane. */
95const statusOf = (
96 current: EmberMood,
97 step: EmberLive,
98 crew: readonly EmberAgent[],
99 at: number,
100 isTurn: boolean,
101 isNarrated: boolean,
102): { color: string; head: string; tail: string } => {
103 const waited = Math.max(0, at - step.since)
104 const steps = step.tools > 0 ? plural(step.tools, 'step') : ''
105 const crewed = crew.length > 0 ? `${plural(crew.length, 'agent')} working` : 'Working'
106
107 if (current.phase === 'work') {
108 // Where the spinner line narrates the step, the band only says who is working.
109 return { color: WORK, head: `● ${isNarrated ? crewed : saying(step, crew, isTurn) || crewed}`, tail: steps }
110 }
111
112 if (current.phase === 'blocked') {
113 return { color: YOU, head: `▲ ${step.label || 'Claude needs you'}`, tail: '' }
114 }
115
116 if (current.phase === 'done') {
117 return { color: READY, head: '◆ Your move', tail: waited >= 60_000 ? span(waited) : '' }
118 }
119
120 return { color: READY, head: '○ ember', tail: '' }
121}
122
123const sound = async ($: EngineInterface, asset: string): Promise<void> => {
124 if (await read($, isMuted)) {
125 return
126 }
127
128 await $.audio.play({ asset }, { gain: 0.6 }).catch(() => undefined)
129}
130
131const toggleSound = async ($: EngineInterface, to?: boolean): Promise<boolean> => {
132 const muted = await update($, isMuted, was => to ?? !was)
133 await $.store.set('isMuted', muted)
134
135 return muted
136}
137
138// Main-loop tool calls in flight, and which wait on him is the newest. A
139// reload starts both over, which a turn's start and end do too.
140let running = 0
141let waits = 0
142// Loops that call tools under an id the engine lists no agent for (a fork, a
143// compaction): not his agents, and asked about once.
144const strangers = new Set<string>()
145
146/** His move has stood a while: one chime, once, then quiet until the next turn ends. */
147const nudge = async ($: EngineInterface, at: number): Promise<void> => {
148 const step = await read($, live)
149
150 if (step.isNudged === true || at - step.since < NUDGE_AFTER_MS) {
151 return
152 }
153
154 await update($, live, held => ({ ...held, isNudged: true }))
155
156 // Said in the chat, so it is still there when he comes back.
157 $.ui.log(`◆ Claude finished ${span(at - step.since)} ago. Your move.`)
158 void sound($, CHIME)
159}
160
161/** Claude cannot go on without him: say so now, chime if it stands. */
162const block = async ($: EngineInterface, label: string, isDecaying: boolean): Promise<void> => {
163 if (!(await read($, isBusy))) {
164 return
165 }
166
167 waits += 1
168 const wait = waits
169 const isStanding = async () => wait === waits && (await read($, mood)).phase === 'blocked'
170
171 await update($, live, held => ({ ...held, label }))
172 await update($, mood, was => feel('blocked', was.gesture))
173
174 $.clock.after(KNOCK_AFTER_MS, () => {
175 void (async () => {
176 if (await isStanding()) {
177 await sound($, CHIME)
178 }
179 })()
180 })
181
182 if (isDecaying) {
183 $.clock.after(BLOCK_DECAY_MS, () => {
184 void (async () => {
185 if (await isStanding()) {
186 await update($, mood, was => feel('work', was.gesture))
187 }
188 })()
189 })
190 }
191}
192
193/** A call came back: he answered whatever it waited on, and with none left Claude is thinking. */
194const settle = async ($: EngineInterface): Promise<void> => {
195 const isIdle = running === 0
196 const was = await read($, mood)
197
198 if (was.phase !== 'work' && was.phase !== 'blocked') {
199 return
200 }
201
202 if (was.phase === 'blocked' || (isIdle && was.gesture !== 'think')) {
203 await update($, mood, held => feel('work', isIdle ? 'think' : held.gesture))
204 }
205
206 // The agents' steps are kept apart from this label, so clearing it never hides what they are on.
207 if (isIdle) {
208 await update($, live, held => ({ ...held, label: 'Thinking' }))
209 }
210}
211
212/** Drops the agents the engine no longer runs: one that was killed, or whose end the mod never heard. */
213const reconcile = async ($: EngineInterface): Promise<EmberAgent[]> => {
214 const crew = await read($, agents)
215
216 if (crew.length === 0) {
217 return crew
218 }
219
220 const listed = await $.agent.list().catch(() => undefined)
221
222 // No list is no news: keep what is known.
223 if (listed === undefined) {
224 return crew
225 }
226
227 const alive = new Set(listed.filter(one => !ENDED.has(one.status)).map(one => one.id))
228 const kept = crew.filter(one => alive.has(one.id))
229
230 return kept.length === crew.length ? crew : update($, agents, list => list.filter(one => alive.has(one.id)))
231}
232
233/** Notes an agent's step and answers whether the loop is one of his agents at all. */
234const track = async ($: EngineInterface, id: string, label: string): Promise<boolean> => {
235 let name = (await read($, agents)).find(one => one.id === id)?.name
236
237 if (name === undefined) {
238 if (strangers.has(id)) {
239 return false
240 }
241
242 const listed = (await $.agent.list().catch(() => [])).find(one => one.id === id && !ENDED.has(one.status))
243
244 if (listed === undefined) {
245 strangers.add(id)
246
247 return false
248 }
249
250 name = clip(listed.description, 40)
251 }
252
253 const known = name
254 // The agent with the newest step goes last: it is the one the narration quotes.
255 await update($, agents, list => [...list.filter(one => one.id !== id), { id, name: known, label }])
256
257 return true
258}
259
260/** Agents are at work with the main loop idle: that is work, not his move. */
261const carryOn = async ($: EngineInterface, at: number): Promise<void> => {
262 const was = await read($, mood)
263
264 if (was.phase === 'done' || was.phase === 'rest') {
265 await update($, live, held => ({ ...held, label: '', since: at }))
266 }
267
268 await update($, mood, held => (held.phase === 'blocked' ? held : feel('work', 'run')))
269}
270
271/** Everything is finished: his move, and a chime if he has had time to wander off. */
272const handOver = async ($: EngineInterface, at: number, isQuiet: boolean): Promise<void> => {
273 const step = await read($, live)
274
275 await update($, now, () => at)
276 await update($, live, held => ({ ...held, label: '', since: at, isNudged: false }))
277 await update($, mood, () => feel('done', 'think'))
278
279 if (!isQuiet && at - step.since >= CHIME_AFTER_MS) {
280 void sound($, CHIME)
281 }
282}
283
284/** With the main loop idle and no agent left at work, the work is over. */
285const wrapUp = async ($: EngineInterface): Promise<void> => {
286 const isOver =
287 (await read($, mood)).phase === 'work' && !(await read($, isBusy)) && (await reconcile($)).length === 0
288
289 if (isOver) {
290 await handOver($, await $.clock.now(), false)
291 }
292}
293
294const tick = async ($: EngineInterface): Promise<void> => {
295 const at = await $.clock.now()
296 await update($, now, () => at)
297
298 if ((await read($, mood)).phase === 'done') {
299 await nudge($, at)
300
301 return
302 }
303
304 // An agent whose end the mod never heard would leave it working forever.
305 await wrapUp($)
306}
307
308export const register: Register = on => {
309 on('session.start', async ($, e, next) => {
310 await $.command
311 .register({
312 name: 'ember',
313 description: 'Ember follows what you and Claude are doing. Switch its chimes, or open the flame',
314 argumentHint: '[mute | unmute | pane]',
315 immediate: true,
316 })
317 .catch(() => $.ui.log('could not register /ember', { to: 'debug' }))
318
319 const wasMuted = (await $.store.get('isMuted')) === true
320 await update($, isMuted, () => wasMuted)
321 // A session still running may hold a mood an earlier version knew and this one does not.
322 await update($, mood, was => (PHASES.includes(was.phase) ? was : feel('done', 'think')))
323
324 // Nothing opens by itself: the mod lives in the chat (the band, the spinner line, a line
325 // of the transcript), and the pane waits for /ember pane. A pane found open at a load was
326 // left by an earlier version that opened one unasked, in a session still running: close it.
327 if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
328 await $.ui.close({ id: PANE }).catch(() => undefined)
329 }
330
331 $.clock.every(HEARTBEAT_MS, () => {
332 void tick($)
333 })
334
335 return next(e)
336 })
337
338 on('command.run', { command: 'ember' }, async ($, e) => {
339 const word = e.args.trim().toLowerCase()
340
341 if (word === 'mute' || word === 'unmute') {
342 const muted = await toggleSound($, word === 'mute')
343
344 return { text: muted ? 'Ember is muted.' : 'Ember chimes when it is your move.' }
345 }
346
347 // The pane is asked for by name: on its own, /ember answers in the chat.
348 if (word === 'pane') {
349 await $.ui.open({ id: PANE, title: 'Ember' })
350
351 return { text: 'Pane opened. Close it with its ✕.' }
352 }
353
354 const muted = await read($, isMuted)
355
356 return {
357 text: `Ember is following this session · sound ${muted ? 'off' : 'on'} · \`/ember ${muted ? 'unmute' : 'mute'}\` · \`/ember pane\` opens the flame`,
358 }
359 })
360
361 on('prompt.submit', async ($, e, next) => {
362 if (!MACHINE.has(e.origin.kind) && !e.text.startsWith('/')) {
363 const { text } = e
364 await update($, ask, () => clip(text, 160))
365 }
366
367 return next(e)
368 })
369
370 on('turn.start', async ($, e, next) => {
371 const at = await $.clock.now()
372 running = 0
373 waits += 1
374
375 // A turn that starts while work is under way (an agent reported in) carries the same stretch on.
376 const isOngoing = (await read($, mood)).phase === 'work'
377 await reconcile($)
378
379 await update($, isBusy, () => true)
380 await update($, now, () => at)
381 await update($, live, held => (isOngoing ? { ...held, label: 'Thinking' } : { label: 'Thinking', since: at, tools: 0 }))
382 await update($, mood, () => feel('work', 'think'))
383
384 return next(e)
385 })
386
387 on('agent.spawn', async ($, e, next) => {
388 const started = await next(e)
389
390 if (started.agentId !== undefined) {
391 const id = started.agentId
392 await update($, agents, list => [...list.filter(one => one.id !== id), { id, name: clip(e.description, 40), label: 'Starting' }])
393
394 if (!(await read($, isBusy))) {
395 await carryOn($, await $.clock.now())
396 }
397 }
398
399 return started
400 })
401
402 on('tool.call', async ($, e, next) => {
403 const said = narrate(e.tool, e as unknown as Readonly<Record<string, unknown>>)
404
405 // An agent's step is kept under the agent: the main loop's own label is not its to write.
406 if (e.agentId !== undefined) {
407 if (await track($, e.agentId, said.label)) {
408 await update($, live, held => ({ ...held, tools: held.tools + 1 }))
409
410 if (!(await read($, isBusy))) {
411 await carryOn($, await $.clock.now())
412 }
413 }
414
415 try {
416 return await next(e)
417 } finally {
418 // Its call came back, so nothing of its waits on him.
419 if ((await read($, mood)).phase === 'blocked') {
420 await update($, mood, was => (was.phase === 'blocked' ? feel('work', was.gesture) : was))
421 }
422 }
423 }
424
425 running += 1
426
427 if (await read($, isBusy)) {
428 await update($, live, held => ({ ...held, label: said.label, tools: held.tools + 1 }))
429 await update($, mood, () => feel('work', said.gesture))
430 }
431
432 if (e.tool === 'AskUserQuestion') {
433 await block($, 'Claude has a question for you', false)
434 }
435
436 try {
437 return await next(e)
438 } finally {
439 running = Math.max(0, running - 1)
440 await settle($)
441 }
442 })
443
444 on('classic.PermissionRequest', async ($, e, next) => {
445 const answered = await next(e)
446
447 // A settings hook that decided it leaves no dialog for him to answer.
448 if (answered.decision === undefined) {
449 await block($, clip(`Needs your OK: ${e.tool_name}`), true)
450 }
451
452 return answered
453 })
454
455 on('turn.complete', async ($, e, next) => {
456 // An agent's turn ended. It may run another (it waits on a shell, then goes on), so the
457 // engine's list says whether it is over; with the main loop idle and none left, the work is.
458 if (e.agentId !== undefined) {
459 const ended = await next(e)
460 await reconcile($)
461 $.clock.after(REPORT_MS, () => {
462 void wrapUp($)
463 })
464
465 return ended
466 }
467
468 const at = await $.clock.now()
469 running = 0
470 waits += 1
471
472 await update($, isBusy, () => false)
473
474 // The main loop stopped with agents still at work: not his move yet.
475 if ((await reconcile($)).length > 0) {
476 await update($, now, () => at)
477 await update($, live, held => ({ ...held, label: '' }))
478 await carryOn($, at)
479 } else {
480 await handOver($, at, e.isAborted)
481 }
482
483 return next(e)
484 })
485
486 // On the terminal the spinner line says what Claude is doing right now, in plain
487 // words, where the engine would say "Pontificating".
488 on('ui.render', { component: 'Spinner', surface: 'terminal' }, async ($, e, next) => {
489 // A message is the engine's own to say.
490 if (e.props.message !== null) {
491 return next(e)
492 }
493
494 const crew = await read($, agents)
495 const own = crew.find(one => one.id === e.requestId)
496
497 // An agent's own spinner says that agent's step.
498 if (own !== undefined) {
499 return next({ ...e, props: { ...e.props, word: own.label } })
500 }
501
502 // A turn the mod did not see start is not its to narrate.
503 if (!(await read($, isBusy))) {
504 return next(e)
505 }
506
507 const said = saying(await read($, live), crew, true)
508 const word = said === 'Thinking' && e.props.mode === 'responding' ? 'Answering' : said
509
510 return next({ ...e, props: { ...e.props, word } })
511 })
512
513 // The band: whose move it is. Once it is his, also what he last asked, for when he comes back to it.
514 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
515 if (e.props.hasSurvey) {
516 return next(e)
517 }
518
519 const current = await read($, mood)
520 const { Box, Text } = $.ui.resolve(e)
521
522 // Before the first prompt this one dim word is how he knows it is there.
523 if (current.phase === 'rest') {
524 return <Text dimColor>○ ember</Text>
525 }
526
527 const step = await read($, live)
528 const crew = await read($, agents)
529 const at = await read($, now)
530 const asked = await read($, ask)
531 // Only the terminal's spinner line narrates the step; elsewhere the band says it.
532 const status = statusOf(current, step, crew, at, await read($, isBusy), e.surface === 'terminal')
533 const goal = current.phase === 'done' && asked !== '' ? `You asked: ${clip(asked, ASK_SHOWN)}` : ''
534
535 // The state comes first and never shrinks; what he asked takes the room that is left and is cut there.
536 return (
537 <Box flexDirection="row" columnGap={2}>
538 <Box flexShrink={0}>
539 <Text color={status.color} bold>
540 {status.head}
541 </Text>
542 </Box>
543 {status.tail !== '' && (
544 <Box flexShrink={0}>
545 <Text dimColor>{status.tail}</Text>
546 </Box>
547 )}
548 {goal !== '' && (
549 <Box flexShrink={1} minWidth={0}>
550 <Text dimColor wrap="truncate-end">
551 {goal}
552 </Text>
553 </Box>
554 )}
555 </Box>
556 )
557 })
558
559 // The companion, opened by /ember pane. It reads the mood and never the clock, and its flame is drawn
560 // from the phase and the heat alone, so the animation restarts when one of them changes and at no other time.
561 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
562 const current = await read($, mood)
563 const step = await read($, live)
564 const crew = await read($, agents)
565 const muted = await read($, isMuted)
566 const look = { ...current, heat: heatOf(current, step) }
567 const { Box, Text, Button } = $.ui.resolve(e)
568 const width = Math.max(120, Math.min(220, e.props.bodyColumns * 7))
569
570 let creature: RenderElement
571
572 if (e.surface === 'terminal') {
573 const status = statusOf(current, step, crew, 0, await read($, isBusy), false)
574 creature = (
575 <Text color={status.color} bold>
576 {status.head}
577 </Text>
578 )
579 } else {
580 const { Svg } = $.ui.resolve(e)
581 creature = (
582 <Svg
583 source={portrait(look, width)}
584 alt={`Ember, a small flame. ${caption(look)}`}
585 width={width}
586 height={Math.round(width * 0.9)}
587 isInteractive
588 />
589 )
590 }
591
592 return (
593 <Box flexDirection="column" alignItems="center" rowGap={1} paddingX={1}>
594 {creature}
595 <Text dimColor>{crew.length > 0 && current.phase === 'work' ? `${plural(crew.length, 'agent')} at work.` : caption(look)}</Text>
596 <Button
597 key="sound"
598 label={muted ? 'Sound is off' : 'Sound is on'}
599 dimColor
600 onPress={() => toggleSound($)}
601 />
602 </Box>
603 )
604 })
605}
606hooks/creature.ts 200 lines1import type { EmberMood } from '../types'
2
3/** What the creature is drawn from: its mood, and how hot the stretch of work runs (0-3). */
4export type Look = EmberMood & { heat: number }
5
6type Palette = readonly [top: string, mid: string, bottom: string]
7
8const PALETTES: readonly [Palette, Palette, Palette, Palette] = [
9 ['#FFC56B', '#F28A3C', '#D9532F'],
10 ['#FFD77A', '#FF9238', '#EE4E2B'],
11 ['#FFE89A', '#FF9E2E', '#F5482A'],
12 ['#FFF6C8', '#FFB02E', '#FF4D2E'],
13]
14const SCALES = [0.86, 0.94, 1, 1.06] as const
15const INK = '#40200F'
16const ALERT = '#FF5D73'
17const EASE = '0.4 0 0.6 1'
18
19// The flame and its core, each as three poses the tip sways between. Cubic
20// segments only, the same in every pose, so the paths interpolate.
21const FLAME = [
22 'M100 28 C118 58 144 84 144 116 C144 140.3 124.3 160 100 160 C75.7 160 56 140.3 56 116 C56 84 82 58 100 28 Z',
23 'M109 31 C123 60 144 86 144 116 C144 140.3 124.3 160 100 160 C75.7 160 56 140.3 56 116 C56 86 85 60 109 31 Z',
24 'M91 31 C115 60 144 86 144 116 C144 140.3 124.3 160 100 160 C75.7 160 56 140.3 56 116 C56 86 77 60 91 31 Z',
25] as const
26const CORE = [
27 'M100 68 C110 84 124 100 124 121 C124 134.3 113.3 145 100 145 C86.7 145 76 134.3 76 121 C76 100 90 84 100 68 Z',
28 'M104 70 C112 85 124 101 124 121 C124 134.3 113.3 145 100 145 C86.7 145 76 134.3 76 121 C76 101 92 85 104 70 Z',
29 'M96 70 C108 85 124 101 124 121 C124 134.3 113.3 145 100 145 C86.7 145 76 134.3 76 121 C76 101 88 85 96 70 Z',
30] as const
31
32/** Seconds per loop of the flicker, the breath and the halo, by phase. */
33const TEMPO = {
34 rest: { flicker: 5, breath: 5, halo: 5 },
35 work: { flicker: 1.8, breath: 2.2, halo: 2.4 },
36 blocked: { flicker: 0.8, breath: 0.9, halo: 0.7 },
37 done: { flicker: 3.2, breath: 3.6, halo: 4 },
38} as const
39
40const loop = (attribute: string, values: readonly string[], seconds: number, extra = '') =>
41 `<animate attributeName="${attribute}" values="${values.join(';')}" dur="${seconds}s" repeatCount="indefinite" ${extra}/>`
42
43const move = (type: 'translate' | 'scale', values: readonly string[], seconds: number, extra = '') =>
44 `<animateTransform attributeName="transform" type="${type}" values="${values.join(';')}" dur="${seconds}s" repeatCount="indefinite" ${extra}/>`
45
46const eased = (steps: number) =>
47 `calcMode="spline" keySplines="${Array.from({ length: steps }, () => EASE).join(';')}"`
48
49const sway = (poses: readonly [string, string, string], seconds: number) =>
50 loop('d', [poses[0], poses[1], poses[0], poses[2], poses[0]], seconds, eased(4))
51
52const eye = (cx: number, { phase }: Look) => {
53 if (phase === 'rest') {
54 return `<path d="M${cx - 6} 117 Q${cx} 122 ${cx + 6} 117" stroke="${INK}" stroke-width="3" stroke-linecap="round" fill="none"/>`
55 }
56
57 const isWide = phase === 'blocked'
58 const ry = isWide ? 8.5 : 7
59 const blink = isWide
60 ? ''
61 : loop('ry', ['7', '7', '0.6', '7', '7'], 4.2, 'keyTimes="0;0.46;0.5;0.54;1"')
62
63 return (
64 `<ellipse cx="${cx}" cy="116" rx="${isWide ? 6 : 5}" ry="${ry}" fill="${INK}">${blink}</ellipse>` +
65 `<circle cx="${cx - 1.6}" cy="113" r="1.7" fill="#fff" opacity="0.9"/>`
66 )
67}
68
69// While Claude works the eyes move over the page. One look for all of it: a look per kind
70// of step would restart the animation on every tool call.
71const gaze = ({ phase }: Look) =>
72 phase === 'work' ? move('translate', ['0 3', '-2 3', '2 3', '0 3'], 1.2, eased(3)) : ''
73
74const mouth = ({ phase }: Look) => {
75 if (phase === 'blocked') {
76 return `<circle cx="100" cy="134" r="3.6" fill="${INK}"/>`
77 }
78
79 const curve =
80 phase === 'done' ? 'M92 130 Q100 138 108 130' : phase === 'rest' ? 'M96 132 Q100 134 104 132' : 'M95 131 Q100 135 105 131'
81
82 return `<path d="${curve}" stroke="${INK}" stroke-width="2.6" stroke-linecap="round" fill="none"/>`
83}
84
85/** What floats around the creature: the one thing that tells the moods apart at a glance. */
86const aura = ({ phase }: Look, [top, mid]: Palette) => {
87 if (phase === 'rest') {
88 const z = (x: number, y: number, size: number, begin: number) =>
89 `<text x="${x}" y="${y}" font-family="system-ui,-apple-system,sans-serif" font-size="${size}" font-weight="700" fill="${mid}" opacity="0">z` +
90 loop('opacity', ['0', '0.9', '0'], 3.2, `begin="${begin}s"`) +
91 move('translate', ['0 6', '5 -8'], 3.2, `begin="${begin}s"`) +
92 '</text>'
93
94 return z(138, 62, 15, 0) + z(152, 44, 20, 1.1)
95 }
96
97 if (phase === 'blocked') {
98 return (
99 '<g>' +
100 `<circle cx="160" cy="44" r="15" fill="${ALERT}"/>` +
101 '<text x="160" y="51.5" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-size="21" font-weight="800" fill="#fff">!</text>' +
102 move('translate', ['0 0', '0 -5', '0 0'], 0.6, eased(2)) +
103 '</g>'
104 )
105 }
106
107 if (phase === 'done') {
108 return (
109 '<g transform="translate(158 46)"><g>' +
110 `<path d="M0 -11 C1.5 -3 3 -1.5 11 0 C3 1.5 1.5 3 0 11 C-1.5 3 -3 1.5 -11 0 C-3 -1.5 -1.5 -3 0 -11 Z" fill="${top}"/>` +
111 move('scale', ['0.5', '1', '0.5'], 2.4, eased(2)) +
112 loop('opacity', ['0.35', '1', '0.35'], 2.4) +
113 '</g></g>'
114 )
115 }
116
117 // Sparks rise off it while Claude works.
118 const spark = (x: number, begin: number) =>
119 `<circle cx="${x}" cy="40" r="3" fill="${top}" opacity="0">` +
120 loop('cy', ['40', '4'], 1.1, `begin="${begin}s"`) +
121 loop('opacity', ['1', '0'], 1.1, `begin="${begin}s"`) +
122 '</circle>'
123
124 return spark(80, 0) + spark(104, 0.3) + spark(124, 0.6) + spark(92, 0.85)
125}
126
127const clampHeat = (heat: number) => Math.max(0, Math.min(3, Math.round(heat)))
128
129/**
130 * The creature as one SVG document, animated with SMIL alone (no script, no
131 * stylesheet): the same look is the same string, so a redraw that changes
132 * nothing leaves the running animation alone.
133 */
134export const portrait = (look: Look, width: number): string => {
135 const heat = clampHeat(look.heat)
136 const palette = PALETTES[heat] ?? PALETTES[0]
137 const scale = SCALES[heat] ?? 1
138 const [top, mid, bottom] = palette
139 const tempo = TEMPO[look.phase]
140 const isBouncing = look.phase === 'blocked'
141 const glow = isBouncing ? ALERT : mid
142 const halo =
143 look.phase === 'rest'
144 ? ['0.2', '0.32', '0.2']
145 : look.phase === 'work'
146 ? ['0.4', '0.7', '0.4']
147 : look.phase === 'done'
148 ? ['0.55', '0.75', '0.55']
149 : ['0.45', '1', '0.45']
150 const bounce = isBouncing
151 ? move('translate', ['0 0', '0 -16', '0 0', '0 -5', '0 0'], 0.9, `keyTimes="0;0.3;0.6;0.8;1" ${eased(4)}`)
152 : ''
153 const breath =
154 look.phase === 'blocked' ? ['1 1', '0.96 1.05', '1 1'] : look.phase === 'rest' ? ['1 1', '1.02 0.975', '1 1'] : ['1 1', '1.035 0.968', '1 1']
155
156 return (
157 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 180" width="${width}" height="${Math.round(width * 0.9)}" style="background:transparent;color-scheme:light dark">` +
158 '<defs>' +
159 `<linearGradient id="flame" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${top}"/><stop offset="0.5" stop-color="${mid}"/><stop offset="1" stop-color="${bottom}"/></linearGradient>` +
160 `<radialGradient id="halo"><stop offset="0" stop-color="${glow}" stop-opacity="0.6"/><stop offset="0.6" stop-color="${glow}" stop-opacity="0.16"/><stop offset="1" stop-color="${glow}" stop-opacity="0"/></radialGradient>` +
161 '</defs>' +
162 `<circle cx="100" cy="108" r="86" fill="url(#halo)">${loop('opacity', halo, tempo.halo, eased(2))}</circle>` +
163 `<ellipse cx="100" cy="168" rx="38" ry="5" fill="#000" opacity="0.14">${isBouncing ? loop('rx', ['38', '26', '38'], 0.9) : ''}</ellipse>` +
164 `<g>${bounce}` +
165 `<g transform="translate(100 160)"><g transform="scale(${scale})"><g>${move('scale', breath, tempo.breath, eased(2))}<g transform="translate(-100 -160)" opacity="${look.phase === 'rest' ? 0.82 : 1}">` +
166 `<path d="${FLAME[0]}" fill="url(#flame)">${sway(FLAME, tempo.flicker)}</path>` +
167 `<path d="${CORE[0]}" fill="#FFF4C2" opacity="${0.5 + heat * 0.12}">${sway(CORE, tempo.flicker * 0.8)}</path>` +
168 '<circle cx="77" cy="128" r="6.5" fill="#FF6B6B" opacity="0.3"/><circle cx="123" cy="128" r="6.5" fill="#FF6B6B" opacity="0.3"/>' +
169 `<g>${gaze(look)}${eye(86, look)}${eye(114, look)}</g>` +
170 mouth(look) +
171 '</g></g></g></g></g>' +
172 aura(look, palette) +
173 '</svg>'
174 )
175}
176
177/** One line under the creature, and what a reader that cannot see it is told. */
178export const caption = ({ phase, gesture }: Look): string => {
179 if (phase === 'rest') {
180 return 'Resting. Ready when you are.'
181 }
182
183 if (phase === 'blocked') {
184 return 'Claude is waiting on you.'
185 }
186
187 if (phase === 'done') {
188 return 'Your move.'
189 }
190
191 const work = {
192 think: 'Claude is thinking.',
193 read: 'Claude is reading.',
194 write: 'Claude is writing.',
195 run: 'Claude is running things.',
196 } as const
197
198 return work[gesture]
199}
200hooks/narrate.ts 96 lines1import type { EmberGesture } from '../types'
2
3/** One tool call in plain words, and how the creature watches it. */
4export type Narration = { gesture: EmberGesture; label: string }
5
6const LIMIT = 56
7
8export const clip = (text: string, limit = LIMIT): string => {
9 const line = text.replace(/\s+/g, ' ').trim()
10
11 return line.length > limit ? `${line.slice(0, limit - 1).trimEnd()}…` : line
12}
13
14const text = (value: unknown): string => (typeof value === 'string' ? value : '')
15
16const fileName = (value: unknown): string => text(value).split(/[\\/]/).pop() || 'a file'
17
18const host = (value: unknown): string => {
19 try {
20 return new URL(text(value)).hostname.replace(/^www\./, '')
21 } catch {
22 return 'a page'
23 }
24}
25
26/** `mcp__plugin_sentry_sentry__search_issues` reads as `sentry: search issues`. */
27const mcp = (tool: string): string => {
28 const [, server = '', name = ''] = tool.split('__')
29 const short = server.replace(/^plugin_/, '').split('_').pop() ?? server
30
31 return `${short}: ${name.replace(/[_-]+/g, ' ')}`
32}
33
34export const narrate = (tool: string, input: Readonly<Record<string, unknown>>): Narration => {
35 if (tool === 'Read') {
36 return { gesture: 'read', label: `Reading ${fileName(input.file_path)}` }
37 }
38
39 if (tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit') {
40 return { gesture: 'write', label: `Editing ${fileName(input.file_path ?? input.notebook_path)}` }
41 }
42
43 if (tool === 'Write') {
44 return { gesture: 'write', label: `Writing ${fileName(input.file_path)}` }
45 }
46
47 if (tool === 'Bash') {
48 return { gesture: 'run', label: clip(text(input.description) || `Running ${text(input.command)}`) }
49 }
50
51 if (tool === 'Grep' || tool === 'Glob') {
52 return { gesture: 'read', label: clip(`Searching for ${text(input.pattern)}`) }
53 }
54
55 if (tool === 'WebFetch') {
56 return { gesture: 'read', label: `Reading ${host(input.url)}` }
57 }
58
59 if (tool === 'WebSearch') {
60 return { gesture: 'read', label: clip(`Searching the web: ${text(input.query)}`) }
61 }
62
63 if (tool === 'Agent' || tool === 'Task') {
64 return { gesture: 'run', label: clip(`Delegating: ${text(input.description) || 'a subtask'}`) }
65 }
66
67 if (tool === 'Skill') {
68 return { gesture: 'read', label: clip(`Loading skill ${text(input.skill)}`) }
69 }
70
71 if (tool === 'AskUserQuestion') {
72 return { gesture: 'think', label: 'Asking you a question' }
73 }
74
75 if (tool.startsWith('Todo') || tool.startsWith('Task') || tool.endsWith('PlanMode')) {
76 return { gesture: 'think', label: 'Planning' }
77 }
78
79 if (tool.startsWith('mcp__')) {
80 return { gesture: 'run', label: clip(mcp(tool)) }
81 }
82
83 return { gesture: 'run', label: `Using ${tool}` }
84}
85
86/** `under a minute`, `12m`, `1h 5m`: how long something has stood. */
87export const span = (ms: number): string => {
88 const minutes = Math.floor(Math.max(0, ms) / 60_000)
89
90 if (minutes < 1) {
91 return 'under a minute'
92 }
93
94 return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
95}
96types/index.d.ts 29 lines1/** What Ember is doing: asleep, watching Claude work, waiting on you now, or your move. */
2export type EmberPhase = 'rest' | 'work' | 'blocked' | 'done'
3
4/** How it watches while Claude works. */
5export type EmberGesture = 'think' | 'read' | 'write' | 'run'
6
7/** What the companion pane draws from: changes only when the creature should. */
8export type EmberMood = { phase: EmberPhase; gesture: EmberGesture }
9
10/** What the band narrates: the current step, when the phase began, steps so far, and whether the idle nudge went out. */
11export type EmberLive = { label: string; since: number; tools: number; isNudged?: boolean }
12
13/** A subagent at work: its id, what it was sent to do, and the step it is on. */
14export type EmberAgent = { id: string; name: string; label: string }
15
16declare module 'claude-code' {
17 interface PluginState {
18 ember: {
19 mood: EmberMood
20 live: EmberLive
21 agents: EmberAgent[]
22 ask: string
23 isMuted: boolean
24 isBusy: boolean
25 now: number
26 }
27 }
28}
29