SLOPSHOPPER

ember

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.

newpanebandspinnerguardcommand
v0.2.0MITupdated 2026-10-03nickdemari/ember
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ember
│ ┃ ember ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Your move │ ┃ ⏺ Read(src/auth.ts) │ ┃ Your move. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ Sound is on ] ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ember │ ⎿ ember: Ember is following this session · sound on · `/ember mute │ │ ◆ Your move You asked: fix the failing auth test and add an audit log call ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ Your move You asked: fix the failing auth test and add an audit log call
Pane · ember
◆ Your move Your move. [ Sound is on ]
README

Ember

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.

What it does

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
  • The spinner line says what Claude is doing, in plain words, where it would say "Pontificating".
  • The band above the prompt says whose move it is: ● 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.
  • Subagents count as work. While agents run, the band says so and the spinner line quotes their latest step. "Your move" and the chime wait until the last one has reported.
  • Chimes. One when work of 20 seconds or more finishes, one when a question or permission prompt has sat for 10 seconds.
  • Idle nudge. Five minutes after Claude finishes with no reply from you: one chime and one line in the transcript, once. The model never reads that line.

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>

Loading it

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.

Checking a change

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.

Layout

  • 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.

License

MIT

Source 4 files
hooks/register.tsx 606 lines
1import { 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}
606
hooks/creature.ts 200 lines
1import 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}
200
hooks/narrate.ts 96 lines
1import 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}
96
types/index.d.ts 29 lines
1/** 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