A progress band with a little coral friend for Claude Code: task and subtask lines, a gradient progress bar, subagent helpers and moods that follow what Claude…

A progress band for Claude Code with a little coral friend. It sits above the prompt and shows what Claude is working on, how far along it is, and what it is doing right now.

The faces are the plugin's own SVG. The bar is drawn as text cells, as it is in the app.
✓ Done. It stays until your next prompt.↳ Explore · find the login code · Reading auth.ts) and a small working copy of the buddy beside the main one (up to three).claude plugin marketplace add karrot0/claude-buddy
claude plugin install progress-buddy@progress-buddy
Start a new session and send a prompt.
Requirements. This plugin uses Claude Code's function-hook plugin API (a hooks module that draws UI), which is early access and still moving between releases. It was built and tested against the Claude desktop app's Code tab (engine 2.1.289). The terminal draws text faces such as (•̀_•́)⌨ instead of the SVG. Older builds will ignore the plugin.
tool.call, turn.complete, prompt.submit and session.start keep a few values in $.state (the step list, the title, the mood, the run state).ui.render hook on AbovePrompt. The face is an SVG; the title, bar and subtask are drawn by a surface module (hooks/band.tsx) that animates on its own frame clock and asks the plugin for fresh text four times a second. That keeps the face from being redrawn on every animation frame.agentId; they are tracked separately and never change the main status.TodoWrite / TaskCreate / TaskUpdate when the session has them. Sessions that don't (the desktop Code tab) get a set_tasks tool (mcp__progress-buddy__set_tasks) that the model calls with a title and the full step list.| Mood | When |
|---|---|
| thinking | after you send a prompt, before any tool runs |
| looking | Read, Grep, Glob, web fetch and search |
| console | shell commands (Bash, PowerShell) |
| tool | every other tool: MCP tools, skills, agents |
| coding | Edit, Write, NotebookEdit |
| oops | a tool call returned an error |
| happy | the turn finished with every step done |
| sleepy | the turn finished with steps still open |
Everything runs locally inside Claude Code. The plugin makes no network requests, spawns no processes, reads and writes no files, and does not depend on or call any other plugin. It uses only these calls on the hook object: $.state.get/set (its own few values), $.clock.every (one timer that cycles the face while idle), $.tool.register (adds one tool, below), $.agent.list (read-only list of running subagents), $.ui.resolve (to draw), and $.ui.invalidate (to redraw the band).
It never submits a prompt of its own, runs a tool or command itself, changes a tool's input, or blocks another tool's call. Every hook below passes the event on unchanged, except that its own tool refuses malformed input.
| Hook | What it does | Changes anything? |
|---|---|---|
tool.call (every tool) | Reads the tool name and a few input fields (file name, search pattern, description, URL, or the first words of a shell command, cut to 120 characters) to print a short "Editing auth.ts" line and pick a mood. Keeps it in memory only; it is shown in the band and never stored or sent. Calls from subagents are tracked separately. Marks the mood "oops" if the call returns an error. Calls next(e) and returns its result untouched. | No |
tool.call for TodoWrite, TaskCreate, TaskUpdate | After the call succeeds, copies the step list into the band. Returns the real result untouched. | No |
tool.call for mcp__progress-buddy__set_tasks | Answers the plugin's own tool (see below). This is the only place the plugin stands in for a tool. | Its own tool only |
prompt.submit | Marks the turn as running. If the previous work is finished and the prompt is one you typed, uses its first line (80 characters) as the new task title. Prompts you did not type (a background task notification, another session, an engine notice, or anything starting with a markup tag) keep the current title. Returns the prompt unchanged. | No |
turn.complete | Marks the run done or idle and picks the happy or sleepy face. | No |
session.start | Registers set_tasks and starts the face-cycling timer. | Adds a tool |
ui.render on AbovePrompt | Draws the band. | UI only |
ui.message | Answers the band's own heartbeat (the number 1, posted by the surface module hooks/band.tsx four times a second) with the text to display. Nothing else is sent anywhere. | No |
ui.fault | Hears that the animated band failed on the surface and switches to the plain bar. Observes only. | No |
mcp__progress-buddy__set_tasks takes a title and a todos list (content, status, optional activeForm). The model calls it at the start of a multi-step task and again whenever a step starts or finishes. The plugin stores the list in session state and returns n/m done. It does nothing else, and it is only registered so sessions without TodoWrite still have a way to report steps.
The test suite is not part of this plugin. It lives in the repository's dev/tests/ folder, outside this folder, and runs against the test kit's mock engine, where it calls tools and prompt.submit to exercise the hooks. Nothing in this folder calls a tool, runs a command or submits a prompt.
hooks/register.tsx (hooks, state and the SVG buddy), hooks/band.tsx (the animated bar and text), types/index.d.ts (state contract), .claude-plugin/icon.png (listing icon).
MIT. Claude Buddy is an unofficial community project and is not affiliated with or endorsed by Anthropic.
hooks/register.tsx 751 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Hat, Jacket, Mood, Outfit, Run, Task } from '../types'
5
6const BODY = '#D97757'
7const SHADE = '#C4623F'
8const INK = '#2B1B14'
9const MARK = '#E9967A'
10
11const KAOMOJI: Record<Mood, string> = {
12 thinking: '(•_• )…',
13 looking: '(◉_◉)',
14 working: '(•̀_•́)',
15 coding: '(•̀_•́)⌨',
16 console: '(•_•)>_',
17 tool: '(•̀ᴗ•́)و',
18 oops: '(°□°)',
19 happy: '(^‿^)',
20 sleepy: '(-_-) z',
21}
22
23const EYE_W = 6.2
24const EYE_H = 12.6
25const EYE_Y = 30
26const LEFT_X = 23.5
27const RIGHT_X = 40.5
28
29// Every pose is plain transforms on the same two capsules, as blobatar does it: scale in the eye's own frame, tilt, then offset.
30const CSS = `
31svg{overflow:visible}
32:root{color-scheme:light dark}
33.sw,.bg{transform-box:view-box;transform-origin:32px 52px}
34.bg{animation:breathe 3.2s ease-in-out infinite alternate}
35.e,.al,.ar,.sh,.sp,.z,.drop{transform-box:fill-box;transform-origin:center}
36.al{transform-origin:100% 50%}
37.ar{transform-origin:0% 50%}
38.b{transform-box:fill-box;transform-origin:center;animation:blink 4.6s infinite}
39@keyframes breathe{to{transform:scale(1.025,.975)}}
40@keyframes blink{0%,94%,100%{transform:scaleY(1)}96.5%{transform:scaleY(.12)}}
41
42.m-thinking .el{transform:translate(0,2.7px) scale(1.18,.62);animation:seesawL .9s ease-in-out infinite alternate}
43.m-thinking .er{transform:translate(0,-2.7px) scale(1.18,.62);animation:seesawR .9s ease-in-out infinite alternate}
44.m-thinking .sw{animation:sway 2.6s ease-in-out infinite alternate}
45@keyframes seesawL{to{transform:translate(0,-2.7px) scale(1.18,.62)}}
46@keyframes seesawR{to{transform:translate(0,2.7px) scale(1.18,.62)}}
47@keyframes sway{from{transform:rotate(-2.5deg)}to{transform:rotate(2.5deg)}}
48
49.m-looking .e{transform:scale(1.05)}
50.m-looking .eyes{animation:glance 3.4s linear infinite}
51@keyframes glance{0%,14%{transform:translate(0,0)}17%,38%{transform:translate(-2.8px,-.5px)}41%,62%{transform:translate(2.8px,.3px)}65%,80%{transform:translate(1.2px,-1.6px)}83%,100%{transform:translate(0,0)}}
52
53.m-working .el{transform:translate(0,.8px) rotate(14deg) scale(1.45,.46)}
54.m-working .er{transform:translate(0,.8px) rotate(-14deg) scale(1.45,.46)}
55.m-working .bg{animation:type .36s ease-in-out infinite alternate}
56.m-working .al{animation:armL .36s ease-in-out infinite alternate}
57.m-working .ar{animation:armR .36s ease-in-out infinite alternate-reverse}
58@keyframes type{to{transform:translateY(-.9px)}}
59@keyframes armL{from{transform:translateY(1px)}to{transform:translateY(-1.6px) rotate(8deg)}}
60@keyframes armR{from{transform:translateY(1px)}to{transform:translateY(-1.6px) rotate(-8deg)}}
61
62.m-coding .el{transform:translate(0,1px) rotate(12deg) scale(1.38,.5)}
63.m-coding .er{transform:translate(0,1px) rotate(-12deg) scale(1.38,.5)}
64.m-coding .eyes{animation:read 2s steps(1,end) infinite}
65.m-coding .bg{animation:hunch .3s ease-in-out infinite alternate}
66.m-coding .al{transform:translate(7px,12px) rotate(-14deg);animation:keysL .17s ease-in-out infinite alternate}
67.m-coding .ar{transform:translate(-7px,12px) rotate(14deg);animation:keysR .17s ease-in-out infinite alternate-reverse}
68.glow{animation:screenlight 1.3s ease-in-out infinite alternate}
69.logo{animation:glow 1.3s ease-in-out infinite alternate;transform-box:fill-box;transform-origin:center}
70.code{opacity:0;animation:rise 2.4s ease-out infinite}
71.code2{animation-delay:.8s}
72.code3{animation-delay:1.6s}
73@keyframes read{0%{transform:translate(-1.5px,-.8px)}25%{transform:translate(1.5px,-.8px)}50%{transform:translate(-1.5px,.4px)}75%{transform:translate(1.5px,.4px)}}
74@keyframes hunch{from{transform:translateY(.4px)}to{transform:translateY(1px)}}
75@keyframes keysL{from{transform:translate(7px,12px) rotate(-14deg)}to{transform:translate(7px,10px) rotate(-14deg)}}
76@keyframes keysR{from{transform:translate(-7px,12px) rotate(14deg)}to{transform:translate(-7px,10px) rotate(14deg)}}
77@keyframes glow{from{opacity:.45}to{opacity:1}}
78@keyframes screenlight{from{opacity:.06}to{opacity:.2}}
79@keyframes rise{0%{opacity:0;transform:translateY(5px)}20%{opacity:1}100%{opacity:0;transform:translateY(-12px)}}
80
81.m-console .e{transform:translate(0,2.2px) scale(1,.9)}
82.m-console .eyes{animation:scan 2.6s ease-in-out infinite alternate}
83.m-console .al{transform:translate(6px,11px) rotate(-12deg)}
84.m-console .ar{transform:translate(-6px,11px) rotate(12deg)}
85.cur{animation:cursor 1s steps(1,end) infinite}
86.ln{transform-box:fill-box;transform-origin:0% 50%;animation:print 2.6s steps(6,end) infinite}
87.ln2{animation-delay:.9s}
88@keyframes scan{from{transform:translate(-1.8px,0)}to{transform:translate(1.8px,0)}}
89@keyframes cursor{0%{opacity:1}50%{opacity:0}}
90@keyframes print{0%{transform:scaleX(0)}55%,100%{transform:scaleX(1)}}
91
92.m-tool .el{transform:translate(0,.6px) rotate(9deg) scale(1.3,.58)}
93.m-tool .er{transform:translate(0,.6px) rotate(-9deg) scale(1.3,.58)}
94.m-tool .bg{animation:type .5s ease-in-out infinite alternate}
95.m-tool .ar{transform:rotate(-34deg)}
96.gear{transform-box:fill-box;transform-origin:center;animation:spin 2.8s linear infinite}
97.gear2{animation-duration:2s;animation-direction:reverse}
98.wrench{transform-box:view-box;transform-origin:56px 30px;animation:tighten .5s ease-in-out infinite alternate}
99@keyframes spin{to{transform:rotate(360deg)}}
100@keyframes tighten{from{transform:rotate(-8deg)}to{transform:rotate(8deg)}}
101
102.m-oops .el{transform:translate(1px,-1.6px) rotate(-12deg) scale(.88,1)}
103.m-oops .er{transform:translate(-1px,-1.6px) rotate(12deg) scale(.88,1)}
104.m-oops .sw{animation:shake .12s linear infinite}
105.m-oops .bg{animation:none}
106.m-oops .al{transform:rotate(26deg)}
107.m-oops .ar{transform:rotate(-26deg)}
108.drop{animation:fall 1.1s ease-in infinite}
109@keyframes shake{0%,100%{transform:translate(.7px,-.3px)}25%{transform:translate(-.8px,.2px)}50%{transform:translate(.4px,.6px)}75%{transform:translate(-.5px,-.6px)}}
110@keyframes fall{0%{transform:translateY(-1px);opacity:0}15%{opacity:1}100%{transform:translateY(9px);opacity:0}}
111
112.m-happy .sw{animation:bounce .66s ease-in-out infinite}
113.m-happy .bg{animation:none}
114.m-happy .sh{animation:land .66s ease-in-out infinite}
115.m-happy .al{animation:waveL .33s ease-in-out infinite alternate}
116.m-happy .ar{animation:waveR .33s ease-in-out infinite alternate}
117.m-happy .b{animation:none}
118.sp{animation:twinkle 1.1s ease-in-out infinite}
119.sp2{animation-delay:.55s}
120@keyframes bounce{0%,100%{transform:translateY(0) scale(1.05,.94)}45%{transform:translateY(-5.5px) scale(.97,1.04)}}
121@keyframes land{0%,100%{transform:scale(1)}45%{transform:scale(.8)}}
122@keyframes waveL{from{transform:rotate(40deg)}to{transform:rotate(14deg)}}
123@keyframes waveR{from{transform:rotate(-40deg)}to{transform:rotate(-14deg)}}
124@keyframes twinkle{0%,100%{opacity:0;transform:scale(.3) rotate(0deg)}50%{opacity:1;transform:scale(1) rotate(45deg)}}
125
126.m-sleepy .el,.m-sleepy .er{transform:translate(0,3.4px) scale(1.15,.2)}
127.m-sleepy .b{animation:none}
128.m-sleepy .bg{transform:translateY(1.4px);animation:snooze 3.6s ease-in-out infinite alternate}
129.m-sleepy .al{transform:rotate(-12deg)}
130.m-sleepy .ar{transform:rotate(12deg)}
131.z{opacity:0;animation:zz 3s ease-out infinite}
132.z2{animation-delay:1s}
133.z3{animation-delay:2s}
134@keyframes snooze{from{transform:translateY(1.4px) scale(1.03,.96)}to{transform:translateY(1.4px) scale(1,1)}}
135@keyframes zz{0%{opacity:0;transform:translate(0,4px)}25%{opacity:1}100%{opacity:0;transform:translate(5px,-9px)}}
136
137@media (prefers-reduced-motion:reduce){*{animation:none!important}}
138`.replace(/\n+/g, '\n')
139
140const eye = (cls: string, cx: number) =>
141 `<g class="e ${cls}"><rect class="b" x="${cx - EYE_W / 2}" y="${EYE_Y - EYE_H / 2}" width="${EYE_W}" height="${EYE_H}" rx="${EYE_W / 2}" fill="${INK}"/></g>`
142
143const arc = (cx: number) =>
144 `<path d="M${cx - 5.3} 33.6Q${cx} 25 ${cx + 5.3} 33.6" fill="none" stroke="${INK}" stroke-width="3.4" stroke-linecap="round"/>`
145
146const star = (cls: string, x: number, y: number, r: number) =>
147 `<path class="sp ${cls}" d="M${x} ${y - r}L${x + r * 0.28} ${y - r * 0.28}L${x + r} ${y}L${x + r * 0.28} ${y + r * 0.28}L${x} ${y + r}L${x - r * 0.28} ${y + r * 0.28}L${x - r} ${y}L${x - r * 0.28} ${y - r * 0.28}Z" fill="#FFD166"/>`
148
149const zed = (cls: string, x: number, y: number, s: number) =>
150 `<path class="z ${cls}" d="M${x} ${y}h${s}l${-s} ${s * 1.2}h${s}" fill="none" stroke="${MARK}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
151
152const CODE = '#7CE7B0'
153
154const glyph = (cls: string, x: number, y: number) =>
155 `<path class="code ${cls}" d="M${x + 3} ${y}l-3 2.6l3 2.6M${x + 5} ${y + 5.6}l2 -6.2M${x + 9} ${y}l3 2.6l-3 2.6" fill="none" stroke="${CODE}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`
156
157// Seen from behind, the lid hides the lower body and feet; the arms reach round to the keys.
158const LAPTOP =
159 `<rect x="12" y="52.6" width="40" height="3.8" rx="1.9" fill="#26262C"/>` +
160 `<rect x="15" y="40" width="34" height="13.4" rx="3" fill="#34343C"/>` +
161 `<rect x="15" y="40" width="34" height="2.2" rx="1.1" fill="#4A4A55"/>` +
162 `<g class="logo" stroke="${BODY}" stroke-width="1.5" stroke-linecap="round"><path d="M32 43.8v5.8M29.1 45.4l5.8 2.6M34.9 45.4l-5.8 2.6"/></g>`
163
164// A terminal window facing us: a blinking prompt and two lines of output printing.
165const TERMINAL =
166 `<rect x="11" y="38.5" width="42" height="18.5" rx="3" fill="#0F1014" stroke="#3A3A44" stroke-width=".8"/>` +
167 `<path d="M11 43.4V41.5a3 3 0 0 1 3-3h36a3 3 0 0 1 3 3v1.9z" fill="#2A2A31"/>` +
168 `<circle cx="14.6" cy="41" r=".95" fill="#FF5F57"/><circle cx="17.6" cy="41" r=".95" fill="#FEBC2E"/><circle cx="20.6" cy="41" r=".95" fill="#28C840"/>` +
169 `<path d="M14.6 46.2l2.4 1.9l-2.4 1.9" fill="none" stroke="${CODE}" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>` +
170 `<rect class="cur" x="18.8" y="49.2" width="3.2" height="1.2" fill="${CODE}"/>` +
171 `<rect class="ln ln1" x="14.4" y="52" width="20" height="1.2" rx=".6" fill="#6B6B78"/>` +
172 `<rect class="ln ln2" x="14.4" y="54.3" width="13" height="1.2" rx=".6" fill="#55555F"/>`
173
174const STEEL = '#B9BEC9'
175
176const gear = (cls: string, cx: number, cy: number, r: number) =>
177 `<g class="gear ${cls}"><circle cx="${cx}" cy="${cy}" r="${r * 0.62}" fill="none" stroke="${STEEL}" stroke-width="${r * 0.5}"/>` +
178 Array.from(
179 { length: 8 },
180 (_, k) =>
181 `<rect x="${cx - r * 0.2}" y="${cy - r}" width="${r * 0.4}" height="${r * 0.42}" rx=".4" fill="${STEEL}" transform="rotate(${k * 45} ${cx} ${cy})"/>`,
182 ).join('') +
183 `</g>`
184
185const WRENCH =
186 `<g class="wrench" fill="none" stroke="${STEEL}" stroke-linecap="round"><path d="M58 27L57 14" stroke-width="2.6"/>` +
187 `<path d="M54.3 12.6a3.2 3.2 0 1 1 5 .7" stroke-width="2.4"/></g>`
188
189const NO_OUTFIT: Outfit = { hat: 'none', jacket: 'none' }
190
191// Clothes are drawn inside the body group, so they ride every pose and animation without knowing about it.
192const HATS: Record<Hat, string> = {
193 none: '',
194 cap:
195 `<path d="M18.5 17Q32 -5 45.5 17Z" fill="#3B82F6"/><path d="M41 15.6Q51 13.8 55.4 18.2Q48 18.6 41.4 17.6Z" fill="#2563EB"/>` +
196 `<circle cx="32" cy="6.6" r="1.3" fill="#2563EB"/>`,
197 beanie:
198 `<path d="M17.6 18Q32 -9 46.4 18Z" fill="#EF6F6C"/><path d="M16.8 15.2h30.4v3.6q-15.2 3-30.4 0z" fill="#D9534F"/>` +
199 `<circle cx="32" cy="3.6" r="3" fill="#FCE9E8"/>`,
200 party:
201 `<path d="M24.5 16L32 -5L39.5 16Z" fill="#8B5CF6"/><path d="M27.3 8.2l7.9 3.2M25.6 13l10.4 2.6" stroke="#FDE047" stroke-width="1.5" stroke-linecap="round"/>` +
202 `<circle cx="32" cy="-5.6" r="2.4" fill="#FDE047"/>`,
203 hardhat:
204 `<path d="M18 17Q32 -7 46 17Z" fill="#FACC15"/><rect x="14.5" y="15.4" width="35" height="3.4" rx="1.7" fill="#EAB308"/>` +
205 `<path d="M32 6.4V15" stroke="#EAB308" stroke-width="2.4" stroke-linecap="round"/>`,
206 nightcap:
207 `<path d="M18.4 17.4Q27 -6 43 5Q51.6 10 53.4 20Q47 14.6 44.6 17Z" fill="#60A5FA"/><path d="M17.4 15.2h28.4v3.4q-14.2 2.6-28.4 0z" fill="#DBEAFE"/>` +
208 `<circle cx="53.6" cy="21.4" r="2.7" fill="#F8FAFC"/>`,
209}
210
211const torso = (fill: string, details: string) =>
212 `<g clip-path="url(#bd)"><path d="M8 42.5Q32 38.5 56 42.5V54H8Z" fill="${fill}"/>${details}</g>`
213
214const JACKETS: Record<Jacket, { sleeve: string; front: string }> = {
215 none: { sleeve: SHADE, front: '' },
216 jacket: {
217 sleeve: '#2F4A7A',
218 front: torso(
219 '#3B5B92',
220 `<path d="M32 40.4V53" stroke="#22365C" stroke-width="1.2"/><path d="M32 40.4h-6.5l6.5 6.6zM32 40.4h6.5l-6.5 6.6z" fill="#587BB8"/>`,
221 ),
222 },
223 hoodie: {
224 sleeve: '#5B4BC4',
225 front: torso(
226 '#6D5BD0',
227 `<path d="M29 40.6v6M35 40.6v6" stroke="#EDE9FE" stroke-width="1.1" stroke-linecap="round"/><path d="M24 47.6h16v3.2h-16z" fill="#5B4BC4"/>`,
228 ),
229 },
230 vest: {
231 sleeve: SHADE,
232 front: torso(
233 '#F59E0B',
234 `<rect x="8" y="45" width="48" height="2.6" fill="#E5E7EB"/><path d="M32 40.4V53" stroke="#B45309" stroke-width="1.2"/>`,
235 ),
236 },
237}
238
239const EXTRAS: Record<Mood, string> = {
240 thinking: '',
241 looking: '',
242 working: '',
243 coding: LAPTOP + glyph('code1', 3, 22) + glyph('code2', 49, 20) + glyph('code3', 25, 12),
244 console: TERMINAL,
245 tool: gear('gear1', 9, 15, 6) + gear('gear2', 18.4, 5.4, 3.6),
246 oops: `<path class="drop" d="M51 8Q47.8 12.6 51 14.8Q54.2 12.6 51 8Z" fill="#7CC4FF"/>`,
247 happy: star('sp1', 53, 10, 4.6) + star('sp2', 10, 14, 3.2),
248 sleepy: zed('z1', 45, 15, 4.4) + zed('z2', 45, 15, 4.4) + zed('z3', 45, 15, 4.4),
249}
250
251const BUDDY = 26
252const HEADROOM = 16
253const FACE_SCALE = 0.75
254const FACE_HEIGHT = Math.round((64 + HEADROOM) * FACE_SCALE)
255const BODY_PATH = 'M32 12C46.3 12 55 19.6 55 32C55 44.4 46.3 52 32 52C17.7 52 9 44.4 9 32C9 19.6 17.7 12 32 12Z'
256
257function figure(mood: Mood, outfit: Outfit): string {
258 const isHappy = mood === 'happy'
259 const jacket = JACKETS[outfit.jacket]
260 const eyes = isHappy
261 ? arc(LEFT_X) + arc(RIGHT_X)
262 : eye('el', LEFT_X) + eye('er', RIGHT_X)
263 const cheeks = isHappy
264 ? `<ellipse cx="14.6" cy="38.2" rx="4" ry="2.4" fill="#FF8FA3" opacity=".6"/><ellipse cx="49.4" cy="38.2" rx="4" ry="2.4" fill="#FF8FA3" opacity=".6"/>`
265 : mood === 'coding'
266 ? `<ellipse class="glow" cx="32" cy="37" rx="15" ry="6.5" fill="#7CE7FF" opacity=".2"/>`
267 : ''
268
269 return (
270 `<g class="m-${mood}">` +
271 `<ellipse class="sh" cx="32" cy="57.6" rx="15" ry="2.4" fill="#000" opacity=".28"/>` +
272 `<g class="sw">` +
273 `<rect x="19" y="47" width="9" height="9.6" rx="3.8" fill="${SHADE}"/><rect x="36" y="47" width="9" height="9.6" rx="3.8" fill="${SHADE}"/>` +
274 `<g class="bg">` +
275 `<rect class="al" x="3.4" y="28" width="9" height="7.4" rx="3.7" fill="${jacket.sleeve}"/><rect class="ar" x="51.6" y="28" width="9" height="7.4" rx="3.7" fill="${jacket.sleeve}"/>` +
276 `<path d="${BODY_PATH}" fill="${BODY}"/>` +
277 `<ellipse cx="22.5" cy="19.6" rx="9" ry="3.6" transform="rotate(-18 22.5 19.6)" fill="#fff" opacity=".17"/>` +
278 jacket.front +
279 cheeks +
280 `<g class="eyes">${eyes}</g>` +
281 HATS[outfit.hat] +
282 (mood === 'tool' ? WRENCH : '') +
283 `</g></g>` +
284 EXTRAS[mood] +
285 `</g>`
286 )
287}
288
289// One small working copy beside the main blob per running subagent. The box has headroom above for hats and the hop.
290function face(mood: Mood, buddies = 0, outfit: Outfit = NO_OUTFIT): string {
291 const n = Math.max(0, Math.min(3, buddies))
292 const width = 64 + n * BUDDY
293 const minis = Array.from(
294 { length: n },
295 (_, i) => `<g transform="translate(${66 + i * BUDDY} 35) scale(.42)">${figure('working', NO_OUTFIT)}</g>`,
296 ).join('')
297
298 return (
299 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 ${-HEADROOM} ${width} ${64 + HEADROOM}" width="${Math.round(width * FACE_SCALE)}" height="${FACE_HEIGHT}">` +
300 `<style>${CSS}</style>` +
301 `<defs><clipPath id="bd"><path d="${BODY_PATH}"/></clipPath></defs>` +
302 figure(mood, outfit) +
303 minis +
304 `</svg>`
305 )
306}
307
308const tasks = atom({ plugin: 'progress-buddy', key: 'tasks' } as const, [])
309const title = atom({ plugin: 'progress-buddy', key: 'title' } as const, '')
310const mood = atom({ plugin: 'progress-buddy', key: 'mood' } as const, 'thinking')
311const run = atom({ plugin: 'progress-buddy', key: 'run' } as const, 'idle')
312
313// ponytail: kept out of $.state so a tool call never redraws the band; the Client pulls it on its heartbeat.
314let activityNow = ''
315const agentActivity: Record<string, string> = {}
316let cycleAt = 0
317
318// Party hat when done, nightcap when asleep, hard hat and hi-vis vest for tools; otherwise an everyday
319// outfit that changes with each new task. The start point comes from the folder the session runs in.
320const EVERYDAY_HATS: Hat[] = ['cap', 'beanie', 'none']
321const EVERYDAY_JACKETS: Jacket[] = ['jacket', 'hoodie', 'none']
322let wardrobe = 0
323
324function outfitFor(feeling: Mood): Outfit {
325 const everydayHat = EVERYDAY_HATS[wardrobe % EVERYDAY_HATS.length]!
326 const everydayJacket = EVERYDAY_JACKETS[Math.floor(wardrobe / EVERYDAY_HATS.length) % EVERYDAY_JACKETS.length]!
327
328 return {
329 hat: feeling === 'happy' ? 'party' : feeling === 'sleepy' ? 'nightcap' : feeling === 'tool' ? 'hardhat' : everydayHat,
330 jacket: feeling === 'tool' ? 'vest' : everydayJacket,
331 }
332}
333
334const CYCLE: Mood[] = ['happy', 'looking', 'sleepy', 'thinking']
335const CYCLE_MS = 4500
336
337// The surface disposes the animated band when it faults (the desktop does so if a render goes unanswered
338// for two seconds). ui.fault says so, and the hooks module then draws a plain bar itself. Each new prompt
339// tries the animated band again, until it has faulted MAX_FAULTS times in the session.
340const PLAIN_CELLS = 30
341const MAX_FAULTS = 3
342let faults = 0
343let isPlain = false
344// What the band was last handed. Redraws hand it the same value again, so a redraw alone never makes the
345// surface render the band; fresh values reach it through its own heartbeat instead.
346let lastSent: Awaited<ReturnType<typeof bandProps>> | undefined
347
348function write($: Parameters<typeof update>[0], fn: (list: Task[]) => Task[]) {
349 return update($, tasks, list => fn(list ?? []))
350}
351
352async function setMood($: Parameters<typeof update>[0], next: Mood) {
353 if ((await read($, mood)) !== next) {
354 await update($, mood, () => next)
355 }
356}
357
358async function setRun($: Parameters<typeof update>[0], next: Run) {
359 if ((await read($, run)) !== next) {
360 await update($, run, () => next)
361 }
362}
363
364async function cycle($: Parameters<typeof update>[0]) {
365 if ((await read($, run)) === 'running') {
366 return
367 }
368
369 cycleAt += 1
370 await setMood($, CYCLE[cycleAt % CYCLE.length]!)
371}
372
373async function runningAgents($: EngineInterface) {
374 const all = await $.agent.list().catch(() => [])
375
376 return all
377 .filter(a => a.teammateId === undefined && a.status === 'running')
378 .map(a => ({ label: (a.description || a.type).slice(0, 60), activity: agentActivity[a.id] ?? 'Starting' }))
379}
380
381async function bandProps($: EngineInterface) {
382 const list = await read($, tasks)
383 const name = await read($, title)
384 const state = await read($, run)
385 const step = list.find(t => t.status === 'in_progress')?.active ?? 'Waiting for the next step'
386 const done = list.filter(t => t.status === 'completed').length
387 const complete = list.length > 0 ? done === list.length : state === 'done'
388
389 return {
390 task: list.length === 0 ? name || (state === 'running' ? 'Working' : 'Ready') : name || step,
391 subtask: activityNow || (list.length > 0 && name ? step : state === 'running' ? 'Thinking' : 'Ready when you are'),
392 done,
393 total: list.length,
394 complete,
395 state,
396 agents: await runningAgents($),
397 }
398}
399
400type Todo = { content: string; status: Task['status']; activeForm?: string }
401
402const STATUSES = ['pending', 'in_progress', 'completed']
403
404function isTodo(t: unknown): t is Todo {
405 const todo = t as Todo | null
406
407 return typeof todo?.content === 'string' && STATUSES.includes(todo.status)
408}
409
410function fromTodos(todos: readonly Todo[]): Task[] {
411 return todos.map((t, i) => ({
412 id: String(i),
413 label: t.content,
414 active: t.activeForm ?? t.content,
415 status: t.status,
416 }))
417}
418
419const OWN_TOOL = 'mcp__progress-buddy__set_tasks'
420const VERBS: Record<string, string> = {
421 Edit: 'Editing',
422 Write: 'Writing',
423 Read: 'Reading',
424 NotebookEdit: 'Editing',
425 Grep: 'Searching for',
426 Glob: 'Finding',
427 Agent: 'Delegating:',
428 Skill: 'Loading skill',
429 WebFetch: 'Fetching',
430 WebSearch: 'Searching the web for',
431}
432
433// What the tool call is doing, in a few words: "Editing register.tsx".
434export function describe(e: Record<string, unknown>): string {
435 const tool = String(e.tool)
436 const text = (key: string) => (typeof e[key] === 'string' ? (e[key] as string) : undefined)
437 const path = text('file_path') ?? text('notebook_path')
438 const subject =
439 path?.split(/[\\/]/).pop() ??
440 text('description') ??
441 text('pattern') ??
442 text('query') ??
443 text('skill') ??
444 text('url') ??
445 text('command')
446
447 if (subject === undefined) {
448 return `Running ${tool.split('__').pop()}`
449 }
450
451 return VERBS[tool] === undefined ? subject : `${VERBS[tool]} ${subject}`
452}
453
454// A turn can start from something the person did not type: a background task's notification, another
455// session's message, an engine notice. Those keep the current task title instead of becoming it.
456const NOT_A_REQUEST = [
457 'task-notification',
458 'peer',
459 'peer-send-message',
460 'projects-relay',
461 'coordinator',
462 'observer',
463 'observer-activity',
464 'unclassified',
465 'plugin',
466]
467
468function requestTitle(text: string, origin: string | undefined): string | undefined {
469 const firstLine =
470 text
471 .split('\n')
472 .find(line => line.trim() !== '')
473 ?.trim() ?? ''
474 const isMarkup = /^<\/?[a-z][\w-]*[\s>]/i.test(firstLine)
475
476 return firstLine === '' || isMarkup || NOT_A_REQUEST.includes(origin ?? '') ? undefined : firstLine.slice(0, 80)
477}
478
479const LOOKING = ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'ToolSearch']
480const CODING = ['Edit', 'Write', 'NotebookEdit']
481const SHELL = ['Bash', 'PowerShell']
482
483export const register: Register = on => {
484 on('tool.call', async ($, e, next) => {
485 const tool = String(e.tool)
486
487 if (e.agentId !== undefined) {
488 agentActivity[e.agentId] = describe(e as Record<string, unknown>).slice(0, 80)
489
490 return next(e)
491 }
492
493 if (tool === OWN_TOOL) {
494 return next(e)
495 }
496
497 activityNow = describe(e as Record<string, unknown>).slice(0, 120)
498
499 if (isPlain) {
500 $.ui.invalidate('ui.render')
501 }
502 await setMood(
503 $,
504 CODING.includes(tool) ? 'coding' : SHELL.includes(tool) ? 'console' : LOOKING.includes(tool) ? 'looking' : 'tool',
505 )
506
507 const ran = await next(e)
508
509 if (ran.isError === true) {
510 await setMood($, 'oops')
511 }
512
513 return ran
514 })
515
516 on('turn.complete', async ($, e, next) => {
517 if (e.agentId !== undefined) {
518 delete agentActivity[e.agentId]
519
520 return next(e)
521 }
522
523 const list = await read($, tasks)
524 const isFinished = list.every(t => t.status === 'completed')
525 activityNow = ''
526 cycleAt = 0
527 await setRun($, isFinished ? 'done' : 'idle')
528 await setMood($, isFinished ? 'happy' : 'sleepy')
529
530 return next(e)
531 })
532
533 // ponytail: own tool because some sessions (desktop Code tab) ship no TodoWrite/TaskCreate.
534 on('tool.call', { tool: OWN_TOOL }, async ($, e) => {
535 const { todos, title: newTitle } = e as { todos?: unknown; title?: unknown }
536
537 if (!Array.isArray(todos) || !todos.every(isTodo)) {
538 return { deny: 'todos must be an array of { content, status, activeForm? }' }
539 }
540
541 await write($, () => fromTodos(todos))
542
543 if (typeof newTitle === 'string') {
544 await update($, title, () => newTitle.slice(0, 120))
545 }
546
547 return { result: `${todos.filter(t => t.status === 'completed').length}/${todos.length} done` }
548 })
549
550 on('session.start', async ($, e, next) => {
551 $.clock.every(CYCLE_MS, () => void cycle($))
552 wardrobe = Array.from(e.cwd).reduce((sum, ch) => (sum * 31 + ch.charCodeAt(0)) % 9973, 7)
553
554 await $.tool.register({
555 name: 'set_tasks',
556 description:
557 'Show the progress of the current task to the user. Call it at the start of any task with more than one step, then again whenever a step starts or finishes. Send the title and the whole list every time. Keep exactly one step in_progress.',
558 inputSchema: {
559 type: 'object',
560 required: ['title', 'todos'],
561 properties: {
562 title: {
563 type: 'string',
564 description: 'What the user asked for, present continuous: "Changing the animation"',
565 },
566 todos: {
567 type: 'array',
568 items: {
569 type: 'object',
570 required: ['content', 'status'],
571 properties: {
572 content: { type: 'string', description: 'The step, imperative: "Run tests"' },
573 activeForm: { type: 'string', description: 'Shown while in progress: "Running tests"' },
574 status: { type: 'string', enum: STATUSES },
575 },
576 },
577 },
578 },
579 },
580 })
581
582 return next(e)
583 })
584
585 on('prompt.submit', async ($, e, next) => {
586 const list = await read($, tasks)
587
588 if (isPlain && faults < MAX_FAULTS) {
589 isPlain = false
590 $.ui.invalidate('ui.render')
591 }
592
593 await setMood($, 'thinking')
594 await setRun($, 'running')
595
596 if (list.every(t => t.status === 'completed')) {
597 const asked = requestTitle(e.text, e.origin?.kind)
598 await write($, () => [])
599
600 if (asked !== undefined) {
601 wardrobe += 4
602 await update($, title, () => asked)
603 }
604 }
605
606 return next(e)
607 })
608
609 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
610 const ran = await next(e)
611
612 if (ran.deny === undefined && ran.isError !== true) {
613 await write($, () => fromTodos(e.todos))
614 }
615
616 return ran
617 })
618
619 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
620 const ran = await next(e)
621
622 if (ran.deny === undefined && ran.isError !== true) {
623 const { id, subject } = ran.result.task
624 await write($, list => [
625 ...list.filter(t => t.id !== id),
626 { id, label: subject, active: e.activeForm ?? subject, status: 'pending' },
627 ])
628 }
629
630 return ran
631 })
632
633 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
634 const ran = await next(e)
635
636 if (ran.deny === undefined && ran.isError !== true && ran.result.success) {
637 const { status } = e
638 await write($, list =>
639 status === 'deleted'
640 ? list.filter(t => t.id !== e.taskId)
641 : list.map(t =>
642 t.id === e.taskId
643 ? {
644 ...t,
645 label: e.subject ?? t.label,
646 active: e.activeForm ?? e.subject ?? t.active,
647 status: status ?? t.status,
648 }
649 : t,
650 ),
651 )
652 }
653
654 return ran
655 })
656
657 on('ui.message', async ($, e, next) => {
658 if (e.element !== 'band') {
659 return next(e)
660 }
661
662 lastSent = await bandProps($)
663
664 return { props: lastSent }
665 })
666
667 on('ui.fault', ($, e, next) => {
668 if (e.element === 'band') {
669 faults += 1
670 isPlain = true
671 lastSent = undefined
672 }
673
674 return next(e)
675 })
676
677 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
678 const props = await bandProps($)
679
680 if (e.props.hasSurvey) {
681 return next(e)
682 }
683
684 const { Box, Text } = $.ui.resolve(e)
685 const feeling: Mood = await read($, mood)
686 let avatar
687 let band
688
689 if (e.surface === 'terminal') {
690 avatar = <Text color="#D97757">{KAOMOJI[feeling]}</Text>
691 } else {
692 const { Svg } = $.ui.resolve(e)
693 const buddies = Math.min(3, props.agents.length)
694 avatar = (
695 <Svg
696 source={face(feeling, buddies, outfitFor(feeling))}
697 alt={`Claude is ${feeling}${buddies > 0 ? `, with ${buddies} helpers` : ''}`}
698 width={Math.round((64 + buddies * BUDDY) * FACE_SCALE)}
699 height={FACE_HEIGHT}
700 />
701 )
702 }
703
704 if (!isPlain && (e.surface === 'terminal' || e.surface === 'desktop')) {
705 lastSent ??= props
706 band = $.ui.resolve(e).Client({ key: 'band', module: './band.tsx', props: lastSent, flexGrow: 1 })
707 } else {
708 const share = props.complete ? 1 : props.total > 0 ? props.done / props.total : 0
709 const filled = Math.round(share * PLAIN_CELLS)
710 const accent = props.complete ? '#4ade80' : '#8b5cf6'
711 const label = props.complete
712 ? props.total > 0
713 ? ` ✓ ${props.done}/${props.total} · 100%`
714 : ' ✓ Done'
715 : props.total > 0
716 ? ` ${props.done}/${props.total} · ${Math.round(share * 100)}%`
717 : ''
718
719 band = (
720 <Box flexDirection="column">
721 <Text bold wrap="truncate-end">
722 {props.task}
723 </Text>
724 <Box>
725 <Text color={accent}>{'█'.repeat(filled)}</Text>
726 <Text dimColor>{'░'.repeat(PLAIN_CELLS - filled)}</Text>
727 <Text bold color={accent}>
728 {label}
729 </Text>
730 </Box>
731 <Text dimColor wrap="truncate-end">
732 {props.complete ? '✓ Done' : props.subtask}
733 </Text>
734 {props.agents.slice(0, 3).map(a => (
735 <Text dimColor wrap="truncate-end">
736 {`↳ ${a.label} · ${a.activity}`}
737 </Text>
738 ))}
739 </Box>
740 )
741 }
742
743 return (
744 <Box gap={1} alignItems="center">
745 {avatar}
746 {band}
747 </Box>
748 )
749 })
750}
751hooks/band.tsx 195 lines1import type { ClientSurface } from 'claude-code'
2
3type Agent = { label: string; activity: string }
4type Props = {
5 task: string
6 subtask: string
7 done: number
8 total: number
9 complete: boolean
10 state: 'idle' | 'running' | 'done'
11 agents: Agent[]
12}
13type State = { frame: number }
14
15const PINK = [
16 [255, 95, 162],
17 [139, 92, 246],
18 [34, 211, 238],
19] as const
20const GREEN = [
21 [22, 163, 74],
22 [74, 222, 128],
23 [16, 185, 129],
24] as const
25const TRACK = [88, 88, 98] as const
26const WHITE = [255, 255, 255] as const
27const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
28const PARTIAL = '▏▎▍▌▋▊▉'
29const FRAME_MS = 60
30const HEARTBEAT_MS = 250
31const SWEEP = 9
32const MAX_AGENT_ROWS = 3
33
34// The animation's own values live here, not in surface.state, so a tick never depends on what an old closure sees.
35let latest: Props = { task: '', subtask: '', done: 0, total: 0, complete: false, state: 'idle', agents: [] }
36let frame = 0
37let shown = 0
38let tone = 0
39// ponytail: one live timer pair per module; a newer start retires the older one, so a start that
40// repeats can never pile timers up. Two bands at once would share one clock; give each its own if that is ever needed.
41let generation = 0
42
43function fraction(p: Props): number {
44 return p.complete ? 1 : p.total === 0 ? 0 : p.done / p.total
45}
46
47function mix(a: readonly number[], b: readonly number[], t: number): number[] {
48 return a.map((v, i) => v + (b[i]! - v) * t)
49}
50
51function hex(c: readonly number[]): string {
52 return '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('')
53}
54
55function gradient(t: number, stops: readonly (readonly number[])[]): number[] {
56 const x = (((t % 1) + 1) % 1) * stops.length
57 const i = Math.floor(x) % stops.length
58
59 return mix(stops[i]!, stops[(i + 1) % stops.length]!, x - Math.floor(x))
60}
61
62function start(surface: ClientSurface<State>) {
63 generation += 1
64 const mine = generation
65 shown = fraction(latest)
66 tone = latest.complete ? 1 : 0
67
68 const stopFrames = surface.every(FRAME_MS, () => {
69 if (mine !== generation) {
70 stopFrames()
71
72 return
73 }
74
75 const gap = fraction(latest) - shown
76 const toneGap = (latest.complete ? 1 : 0) - tone
77
78 // Nothing moves while idle, so ask for no redraw: every redraw is one more render the surface must answer in time.
79 if (latest.state === 'idle' && gap === 0 && toneGap === 0) {
80 return
81 }
82
83 frame += 1
84 shown = Math.abs(gap) < 0.002 ? fraction(latest) : shown + gap * 0.22
85 tone = Math.abs(toneGap) < 0.01 ? (latest.complete ? 1 : 0) : tone + toneGap * 0.14
86 surface.setState({ frame })
87 })
88 const stopBeats = surface.every(HEARTBEAT_MS, () => {
89 if (mine !== generation) {
90 stopBeats()
91
92 return
93 }
94
95 surface.post(1)
96 })
97}
98
99export default function Band(p: Props, surface: ClientSurface<State>) {
100 latest = p
101 const { Box, Text } = surface.elements
102
103 if (surface.state === undefined) {
104 surface.setState({ frame })
105 start(surface)
106 }
107
108 const agents = p.agents ?? []
109 const cells = Math.max(10, Math.min(44, (surface.columns || 36) - 12))
110 const flow = frame * 0.016
111 const glint = ((frame * (0.9 + 0.75 * tone)) % (cells + 20)) - 10
112 const pulse = tone * (0.5 + 0.5 * Math.sin(frame * 0.18)) * 0.16
113 const isSweep = p.state === 'running' && p.total === 0 && !p.complete
114 const isEmpty = p.total === 0 && !p.complete && !isSweep
115 const eighths = Math.round(shown * cells * 8)
116 const full = Math.floor(eighths / 8)
117 const rest = eighths % 8
118 const head = (frame * 0.68) % (cells + SWEEP)
119
120 const colour = (i: number) =>
121 mix(gradient((i / cells) * 0.9 - flow, PINK), gradient((i / cells) * 0.9 - flow, GREEN), tone)
122
123 const bar = Array.from({ length: cells }, (_, i) => {
124 const base = colour(i)
125
126 if (isSweep) {
127 const behind = head - i
128
129 return behind >= 0 && behind < SWEEP ? (
130 <Text color={hex(mix(TRACK, base, 1 - behind / SWEEP))}>█</Text>
131 ) : (
132 <Text color={hex(TRACK)}>░</Text>
133 )
134 }
135
136 if (isEmpty) {
137 return <Text color={hex(mix(TRACK, base, Math.max(0, 1 - Math.abs(i - glint) / 8) * 0.4))}>░</Text>
138 }
139
140 if (i > full || (i === full && rest === 0)) {
141 return <Text color={hex(TRACK)}>░</Text>
142 }
143
144 const shine = Math.max(0, 1 - Math.abs(i - glint) / 6) * (0.5 + 0.25 * tone) + pulse
145 const lit = mix(base, WHITE, Math.min(0.85, shine))
146
147 return <Text color={hex(lit)}>{i === full ? PARTIAL[rest - 1] : '█'}</Text>
148 })
149
150 const accent = hex(colour(0))
151 const spinner = SPINNER[Math.floor(frame / 2) % SPINNER.length]
152 const label = p.complete
153 ? p.total > 0
154 ? ` ✓ ${p.done}/${p.total} · 100%`
155 : ' ✓ Done'
156 : p.total > 0
157 ? ` ${p.done}/${p.total} · ${Math.round(fraction(p) * 100)}%`
158 : ''
159
160 if (label !== '') {
161 bar.push(
162 <Text bold color={accent}>
163 {label}
164 </Text>,
165 )
166 }
167
168 const lines = [
169 <Text bold wrap="truncate-end">
170 {p.task}
171 </Text>,
172 <Box>{bar}</Box>,
173 p.complete ? (
174 <Text color={accent} wrap="truncate-end">
175 ✓ Done
176 </Text>
177 ) : (
178 <Text dimColor wrap="truncate-end">
179 {p.state === 'running' ? `${spinner} ${p.subtask}` : p.subtask}
180 </Text>
181 ),
182 ...agents.slice(0, MAX_AGENT_ROWS).map(a => (
183 <Text dimColor wrap="truncate-end">
184 {`↳ ${spinner} ${a.label} · ${a.activity}`}
185 </Text>
186 )),
187 ]
188
189 if (agents.length > MAX_AGENT_ROWS) {
190 lines.push(<Text dimColor>{`↳ +${agents.length - MAX_AGENT_ROWS} more agents`}</Text>)
191 }
192
193 return <Box flexDirection="column">{lines}</Box>
194}
195types/index.d.ts 20 lines1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Task = { id: string; label: string; active: string; status: TaskStatus }
4
5export type Mood = 'thinking' | 'looking' | 'working' | 'coding' | 'console' | 'tool' | 'oops' | 'happy' | 'sleepy'
6
7export type Run = 'idle' | 'running' | 'done'
8
9export type Hat = 'none' | 'cap' | 'beanie' | 'party' | 'hardhat' | 'nightcap'
10
11export type Jacket = 'none' | 'jacket' | 'hoodie' | 'vest'
12
13export type Outfit = { hat: Hat; jacket: Jacket }
14
15declare module 'claude-code' {
16 interface PluginState {
17 'progress-buddy': { tasks: Task[]; title: string; mood: Mood; run: Run }
18 }
19}
20