An office of your Claude Code session: the main session and each subagent at their desks, with model, progress, cost, tokens and time; plus a progress band…

A Claude Code mod: an office seen from above, where the main session is the boss at the big desk and each subagent works at its own desk, plus a minimal progress band above the prompt.
Based on savvy-progress by johnnyvizz (MIT).
/agents-info toggles it): a score strip on top (cost, tokens and time; total, boss and team), the boss's desk with the current prompt, a desk per subagent typing with a balloon of its task and progress, finished agents having coffee in the pantry with a ✓ on their desk, planned tasks as empty desks. Chair color is the model: Opus violet, Sonnet blue, Haiku green, Fable amber. Floors are added as the team grows. Light and dark themes; prefers-reduced-motion stops the animation.auto (default), en, pt, ru.Cost is an estimate from token counts and a built-in price table (PRICES in hooks/register.tsx), not a bill.
Clone it and name the folder in ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/savvy-progress" } }
Cloud sessions do not see your machine's settings, so configure the cloud environment at claude.ai/code:
Setup script
#!/bin/bash
git clone --depth 1 https://github.com/leolimaferreira/savvy-progress-office.git ~/.claude/mods/savvy-progress || true
Environment variables
CLAUDE_CODE_PLUGIN_DIRS=~/.claude/mods/savvy-progress
The language follows the session's locale; to pin it, set pluginConfigs in a settings file the session reads, for example the repository's .claude/settings.json:
{ "pluginConfigs": { "savvy-progress": { "options": { "language": "pt" } } } }hooks/register.tsx 988 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Flow, MainRun, Panel, Phase, PlannedTask } from '../types'
5import { BAND_H, SCORE_H, bandSvg, colorOfFamily, familyOf, officeSvg, scoreSvg } from './office'
6import type { Band, Desk, Part as OfficePart, Words } from './office'
7
8const flow = atom({ plugin: 'savvy-progress', key: 'flow' } as const, null)
9const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, [])
10const panel = atom({ plugin: 'savvy-progress', key: 'panel' } as const, {
11 isCompact: false,
12 isDoneCollapsed: false,
13 autoOpenedFor: '',
14 isBandHidden: false,
15})
16const main = atom({ plugin: 'savvy-progress', key: 'main' } as const, {
17 model: '',
18 status: 'idle',
19 task: '',
20 activeMs: 0,
21 tokens: 0,
22 costUsd: 0,
23 contextTokens: 0,
24 contextMax: 0,
25 steps: 0,
26 turns: 0,
27} as MainRun)
28const now = atom({ plugin: 'savvy-progress', key: 'now' } as const, 0)
29
30const TOOL = 'mcp__savvy-progress__progress'
31const STEP_TOOL = 'mcp__savvy-progress__step'
32const PANE = 'savvy-agents'
33const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
34const DONE = '#5fbf8f'
35
36type ProgressInput = {
37 title?: string
38 total?: number
39 done?: number
40 phase?: Phase
41 finished?: boolean
42 tasks?: { title?: string; tier?: string; after?: number[] }[]
43}
44
45// ---------------------------------------------------------------------------
46// Language: the `language` option, else Claude Code's `language` setting, else the
47// process locale; English when nothing says Portuguese or Russian.
48
49type Lang = 'en' | 'pt' | 'ru'
50
51const STRINGS = {
52 en: {
53 pane: 'Office',
54 cost: 'Cost',
55 tokens: 'Tokens',
56 time: 'Time',
57 collapse: 'Collapse',
58 expand: 'Expand',
59 running: 'Running',
60 finished: 'Finished',
61 planned: 'Planned',
62 empty: 'No subagents yet.',
63 round: 'round',
64 failed: 'error',
65 after: 'after',
66 tokensWord: 'tokens',
67 agentsCount: 'agents',
68 isRunning: 'running',
69 isFinished: 'finished',
70 isPlanned: 'planned',
71 opened: 'Office opened.',
72 closed: 'Office closed.',
73 done: 'Done',
74 plan: 'Plan',
75 design: 'Design',
76 tasks: 'Tasks',
77 review: 'Review',
78 busy: 'running',
79 main: 'Main session',
80 subagents: 'Subagents',
81 total: 'Total',
82 idle: 'waiting',
83 pantry: 'Pantry',
84 floor: 'Floor {n}',
85 mainShort: 'Boss',
86 subsShort: 'Team',
87 },
88 pt: {
89 pane: 'Escritório',
90 cost: 'Custo',
91 tokens: 'Tokens',
92 time: 'Tempo',
93 collapse: 'Recolher',
94 expand: 'Expandir',
95 running: 'Trabalhando',
96 finished: 'Concluídos',
97 planned: 'Planejados',
98 empty: 'Nenhum subagente ainda.',
99 round: 'rodada',
100 failed: 'erro',
101 after: 'depois de',
102 tokensWord: 'tokens',
103 agentsCount: 'agentes',
104 isRunning: 'trabalhando',
105 isFinished: 'concluído',
106 isPlanned: 'planejada',
107 opened: 'Escritório aberto.',
108 closed: 'Escritório fechado.',
109 done: 'Concluído',
110 plan: 'Plano',
111 design: 'Design',
112 tasks: 'Tarefas',
113 review: 'Revisão',
114 busy: 'em andamento',
115 main: 'Sessão principal',
116 subagents: 'Subagentes',
117 total: 'Total',
118 idle: 'aguardando',
119 pantry: 'Copa',
120 floor: '{n}º andar',
121 mainShort: 'Chefe',
122 subsShort: 'Equipe',
123 },
124 ru: {
125 pane: 'Офис',
126 cost: 'Стоимость',
127 tokens: 'Токены',
128 time: 'Время',
129 collapse: 'Свернуть',
130 expand: 'Развернуть',
131 running: 'Работают',
132 finished: 'Завершены',
133 planned: 'Запланированы',
134 empty: 'Субагентов пока нет.',
135 round: 'раунд',
136 failed: 'ошибка',
137 after: 'после',
138 tokensWord: 'токенов',
139 agentsCount: 'агентов',
140 isRunning: 'работает',
141 isFinished: 'завершён',
142 isPlanned: 'запланирована',
143 opened: 'Офис открыт.',
144 closed: 'Офис закрыт.',
145 done: 'Готово',
146 plan: 'План',
147 design: 'Дизайн',
148 tasks: 'Задачи',
149 review: 'Ревью',
150 busy: 'в работе',
151 main: 'Основная сессия',
152 subagents: 'Субагенты',
153 total: 'Итого',
154 idle: 'ожидает',
155 pantry: 'Кухня',
156 floor: 'Этаж {n}',
157 mainShort: 'Шеф',
158 subsShort: 'Команда',
159 },
160} as const
161
162// Module scope is fine here: session.start sets it again on every (re)load.
163let lang: Lang = 'en'
164const tr = () => STRINGS[lang]
165
166// `pt`, `pt_BR.UTF-8`, `Portuguese`, `português`; `ru`, `Russian`, `русский`.
167const langOf = (v: unknown): Lang => {
168 if (typeof v !== 'string') return 'en'
169 const t = v.trim()
170 if (/^(pt|portug)/i.test(t)) return 'pt'
171 if (/^(ru|russian|рус)/i.test(t)) return 'ru'
172 return 'en'
173}
174
175async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
176 if (option === 'en' || option === 'pt' || option === 'ru') return option
177 try {
178 const settings = (await $.settings.read()) as Record<string, unknown>
179 if (typeof settings.language === 'string' && settings.language.trim()) return langOf(settings.language)
180 } catch {
181 // No settings: fall through to the locale.
182 }
183 // "C" and "POSIX" say nothing; the next variable may.
184 const vars = [await $.env.get('LC_ALL'), await $.env.get('LC_MESSAGES'), await $.env.get('LANG')]
185 const locale = vars.find(v => v && !/^(C|POSIX)(\.|$)/.test(v))
186 if (locale) return langOf(locale)
187 // macOS keeps the system language out of the environment: the first of
188 // AppleLanguages, `("pt-BR", "en-US")`, is the one the system shows.
189 try {
190 const { exitCode, stdout } = await $.process.run(['defaults', 'read', '-g', 'AppleLanguages'], { timeoutMs: 2000 })
191 if (exitCode === 0) return langOf(/"?([A-Za-z-]+)"?/.exec(stdout.replace(/^\s*\(/, ''))?.[1])
192 } catch {
193 // Not macOS, or no answer: English.
194 }
195 return 'en'
196}
197
198const blank = (): Flow => ({
199 title: 'savvy-flow',
200 total: 0,
201 done: 0,
202 running: 0,
203 phase: 'plan',
204 isFinished: false,
205 tasks: [],
206})
207
208const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
209 !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
210
211const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
212 tasks
213 ?.filter(t => t.title?.trim())
214 .map(t => ({
215 title: (t.title ?? '').trim(),
216 tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
217 after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
218 }))
219
220const merge = (prev: Flow | null, input: ProgressInput): Flow => {
221 // A new title means a new flow: never carry counters over from an earlier one.
222 const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
223 const tasks = cleanTasks(input.tasks) ?? base.tasks
224 const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
225 const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
226 const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
227 return {
228 ...base,
229 title: input.title?.trim() || base.title,
230 total,
231 done,
232 phase: input.finished ? 'close' : phase,
233 isFinished: input.finished === true,
234 tasks,
235 }
236}
237
238const label = (f: Flow): string => {
239 const s = tr()
240 if (f.isFinished) return s.done
241 if (f.phase === 'plan') return s.plan
242 if (f.phase === 'design') return s.design
243 const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
244 return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
245}
246
247const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
248const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
249
250const barText = (f: Flow, width: number): string => {
251 const filled = Math.round(width * ratio(f))
252 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
253}
254// ---------------------------------------------------------------------------
255// Agents panel: every subagent of the session, plus the tasks the flow planned.
256
257const TIER_COLOR: Record<string, string> = {
258 fable: '#7F77DD',
259 heavy: '#D85A30',
260 careful: '#BA7517',
261 medium: '#378ADD',
262 light: '#1D9E75',
263 other: '#888780',
264}
265
266
267const TIER_MODEL: Record<string, string> = {
268 fable: 'Fable · high',
269 heavy: 'Opus · xhigh',
270 careful: 'Opus · high',
271 medium: 'Opus · medium',
272 light: 'Opus · low',
273}
274
275// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
276// The engine reports tokens, not money, so the panel's cost is an estimate.
277const PRICES: [RegExp, [number, number, number, number]][] = [
278 [/fable|mythos/, [10, 50, 0.25, 12.5]],
279 [/opus-5-5/, [4, 20, 0.2, 5]],
280 [/opus/, [5, 25, 0.5, 6.25]],
281 [/sonnet/, [2, 10, 0.2, 2.5]],
282 [/haiku/, [1, 5, 0.1, 1.25]],
283]
284
285type Usage = {
286 input_tokens: number
287 output_tokens: number
288 cache_read_input_tokens: number
289 cache_creation_input_tokens: number
290}
291
292const priceOf = (model: string): [number, number, number, number] =>
293 PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
294
295const costOf = (model: string, u: Usage): number => {
296 const [i, o, r, w] = priceOf(model)
297 return (
298 ((u.input_tokens || 0) * i +
299 (u.output_tokens || 0) * o +
300 (u.cache_read_input_tokens || 0) * r +
301 (u.cache_creation_input_tokens || 0) * w) /
302 1e6
303 )
304}
305
306const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
307
308// `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin.
309const tierOf = (type: string): string => {
310 const bare = type.replace(/^[^:]*:/, '')
311 const t = bare.replace(/^savvy-/, '').toLowerCase()
312 return t in TIER_COLOR && bare.startsWith('savvy-') ? t : 'other'
313}
314
315const modelName = (id: string): string => {
316 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
317 const [, family = '', major = '', minor] = m ?? []
318 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
319 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
320}
321
322const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
323
324const fmtTokens = (n: number): string =>
325 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
326
327const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
328
329const fmtTime = (ms: number): string => {
330 const s = Math.max(0, Math.round(ms / 1000))
331 const h = Math.floor(s / 3600)
332 const m = Math.floor((s % 3600) / 60)
333 const ss = String(s % 60).padStart(2, '0')
334 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
335}
336
337const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
338
339type Planned = PlannedTask & { n: number }
340
341const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
342 if (!f || f.isFinished) return []
343 const started = new Set(list.map(a => norm(a.description)))
344 return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
345}
346
347const mainTime = (m: MainRun, at: number): number =>
348 m.activeMs + (m.status === 'working' && m.turnStartedAt ? Math.max(0, at - m.turnStartedAt) : 0)
349
350type Part = { cost: number; tokens: number; time: number }
351
352// The main session and the subagents apart, and together. Subagents mostly run
353// inside a main turn, so the total time is the longer of the two, not the sum.
354const totals = (list: AgentRun[], at: number, m: MainRun): Part & { main: Part; subs: Part } => {
355 const start = Math.min(...list.map(a => a.startedAt))
356 const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
357 const subs = {
358 cost: list.reduce((s, a) => s + a.costUsd, 0),
359 tokens: list.reduce((s, a) => s + a.tokens, 0),
360 time: list.length ? end - start : 0,
361 }
362 const own = { cost: m.costUsd, tokens: m.tokens, time: mainTime(m, at) }
363 return { cost: own.cost + subs.cost, tokens: own.tokens + subs.tokens, time: Math.max(own.time, subs.time), main: own, subs }
364}
365
366const usageTokens = (u: Usage): number =>
367 (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
368
369const hasMain = (m: MainRun): boolean => m.tokens > 0 || m.costUsd > 0 || m.status === 'working'
370// The task's own progress when the worker reports steps; a finished run is full.
371const progressOf = (a: AgentRun): number | null => {
372 if (a.status === 'done') return 1
373 if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
374 return null
375}
376
377const ctxOf = (a: { contextTokens: number; contextMax: number }): number =>
378 a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0
379
380const modelLabel = (model: string, effort?: string): string =>
381 effort ? `${modelName(model)} · ${effort}` : modelName(model)
382
383// A desk nobody sits at yet takes the chair of what its tier runs on.
384const tierFamily = (tier: string): string => (tier === 'fable' ? 'fable' : tier in TIER_MODEL ? 'opus' : 'other')
385
386// At most this many desks; the oldest finished runs give theirs up first.
387const MAX_DESKS = 40
388
389const desksOf = (list: AgentRun[], planned: Planned[]): Desk[] => {
390 const running = list.filter(a => a.status === 'running')
391 const finished = list.filter(a => a.status !== 'running')
392 const room = Math.max(0, MAX_DESKS - running.length - planned.length)
393 const toDesk = (a: AgentRun): Desk => ({
394 key: a.id,
395 title: a.description || a.type,
396 family: familyOf(a.model),
397 model: a.model ? modelLabel(a.model, a.effort) : tierOf(a.type),
398 status: a.status,
399 progress: progressOf(a),
400 steps: a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' · ' + a.stepNote : ''}` : '',
401 })
402 return [
403 ...running.map(toDesk),
404 ...(room ? finished.slice(-room) : []).map(toDesk),
405 ...planned.map(p => ({
406 key: `plan-${p.n}`,
407 title: p.title,
408 family: tierFamily(p.tier),
409 model: TIER_MODEL[p.tier] ?? p.tier,
410 status: 'planned' as const,
411 progress: null,
412 steps: '',
413 n: p.n,
414 after: p.after,
415 })),
416 ]
417}
418
419const fmtPart = (p: Part): OfficePart => ({ cost: '≈' + fmtCost(p.cost), tokens: fmtTokens(p.tokens), time: fmtTime(p.time) })
420
421const words = (): Words => {
422 const s = tr()
423 return { main: s.main, subagents: s.subagents, total: s.total, cost: s.cost, tokens: s.tokens, time: s.time, idle: s.idle, pantry: s.pantry, floor: s.floor, after: s.after, mainShort: s.mainShort, subsShort: s.subsShort }
424}
425
426const mainLabel = (m: MainRun): string =>
427 `${m.status === 'working' ? tr().isRunning : tr().idle} · ${fmtTokens(m.tokens)} ${tr().tokensWord}`
428
429const partLine = (p: Part): string => `≈${fmtCost(p.cost)} · ${fmtTokens(p.tokens)} · ${fmtTime(p.time)}`
430
431const ctxBar = (pct: number, width: number): string => {
432 const filled = Math.round((width * pct) / 100)
433 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
434}
435
436const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
437
438// Opens the office, or closes it when it is up; true when it ends up open.
439// Opening it brings back a band the person dismissed.
440async function togglePane($: EngineInterface): Promise<boolean> {
441 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
442 if (isOpen) {
443 await $.ui.close({ id: PANE })
444 return false
445 }
446 const at = await $.clock.now()
447 await update($, now, () => at)
448 await update($, panel, prev => ({ ...prev, isBandHidden: false }))
449 await $.ui.open({ id: PANE, title: tr().pane })
450 return true
451}
452
453async function autoOpen($: EngineInterface, key: string): Promise<void> {
454 const p = await read($, panel)
455 if (p.autoOpenedFor === key) return
456 await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
457 void $.ui.open({ id: PANE, title: tr().pane })
458}
459
460export const register: Register = (on, options) => {
461 on('session.start', async ($, e, next) => {
462 const started = await next(e)
463 lang = await detectLang($, options.language)
464 await $.tool.register({
465 name: 'progress',
466 description:
467 'Report /savvy-flow progress to the progress bar above the prompt and the agents panel. ' +
468 'Call it after presenting the plan (title, total, tasks, phase "delegate"), each time a task is accepted (done), ' +
469 'when switching phase or re-planning (tasks), and once at the end with finished: true. Fields left out keep their previous value.',
470 inputSchema: {
471 type: 'object',
472 properties: {
473 title: { type: 'string', description: 'Short name of the overall task, a few words.' },
474 total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks.' },
475 done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted after review.' },
476 phase: { type: 'string', enum: [...PHASES] },
477 finished: { type: 'boolean', description: 'True once the flow is closed.' },
478 tasks: {
479 type: 'array',
480 description:
481 'The planned worker tasks in order, numbered from 1. Each title must equal the Agent tool `description` the task will be delegated with, so the panel can match runs to tasks.',
482 items: {
483 type: 'object',
484 properties: {
485 title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
486 tier: { type: 'string', enum: ['fable', 'heavy', 'careful', 'medium', 'light'] },
487 after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
488 },
489 required: ['title', 'tier'],
490 },
491 },
492 },
493 },
494 })
495 await $.tool.register({
496 name: 'step',
497 description:
498 'For savvy-flow workers: report progress on your own task to the agents panel. ' +
499 'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
500 'call it again as each step finishes. Cheap and silent: it only draws a bar.',
501 inputSchema: {
502 type: 'object',
503 properties: {
504 done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
505 total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
506 note: { type: 'string', description: 'The step in progress, a few words.' },
507 },
508 required: ['done'],
509 },
510 })
511 await $.command.register({
512 name: 'agents-info',
513 description: 'Show or hide the office: the main session and every subagent at their desks, with model, progress, cost, tokens and time',
514 })
515
516 // Loaded into a session already under way: what it spent before is in the
517 // session's own ledger (/cost), minus what the subagents here account for.
518 try {
519 const m = await read($, main)
520 if (m.turns === 0 && m.costUsd === 0) {
521 const usage = await $.session.usage()
522 const model = await $.session.model()
523 const list = await read($, agents)
524 const subs = list.reduce((s, a) => s + a.costUsd, 0)
525 await update($, main, prev => ({
526 ...prev,
527 costUsd: Math.max(0, (usage.cost?.usd ?? 0) - subs),
528 model: prev.model || model,
529 contextTokens: usage.context.tokens ?? prev.contextTokens,
530 contextMax: usage.context.window || prev.contextMax,
531 }))
532 }
533 } catch {
534 // No ledger here: the count starts with the next turn.
535 }
536
537 // Ticks the running clocks; quiet when nothing runs.
538 $.clock.every(1000, () => {
539 void (async () => {
540 const list = await read($, agents)
541 const m = await read($, main)
542 if (!list.some(a => a.status === 'running') && m.status !== 'working') return
543 const at = await $.clock.now()
544 await update($, now, () => at)
545 })()
546 })
547 return started
548 })
549
550 on('command.run', { command: 'agents-info' }, async $ => {
551 const isOpen = await togglePane($)
552 return { text: isOpen ? tr().opened : tr().closed }
553 })
554
555 on('tool.call', { tool: TOOL }, async ($, e) => {
556 const input = e as unknown as ProgressInput
557 const prev = await read($, flow)
558 if (isNewFlow(prev, input) && input.title !== undefined) {
559 // A new flow starts with a clean list; agents still running stay.
560 await update($, agents, list => list.filter(a => a.status === 'running'))
561 await update($, panel, prev => ({ ...prev, isBandHidden: false }))
562 }
563 const next = await update($, flow, p => merge(p, input))
564 if (next && input.tasks?.length) await autoOpen($, next.title)
565 return { result: `ok: ${label(next ?? blank())}` }
566 })
567
568 // A worker's own progress: the call runs in the worker's loop, so agentId names it.
569 on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
570 const input = e as unknown as { done?: number; total?: number; note?: string }
571 const agentId = e.agentId
572 if (!agentId) return { result: 'ignored: only subagents report steps' }
573 await update($, agents, list =>
574 list.map(a => {
575 if (a.agentId !== agentId) return a
576 const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
577 const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
578 return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
579 }),
580 )
581 return { result: 'ok' }
582 })
583
584 // Safety net: worker launches move the faint layer even if the orchestrator forgets to report.
585 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
586 const type = String(e.subagent_type ?? '')
587 if (!type.startsWith('savvy-')) return next(e)
588
589 await update($, flow, prev => {
590 const base = prev && !prev.isFinished ? { ...blank(), ...prev } : blank()
591 return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
592 })
593 try {
594 return await next(e)
595 } finally {
596 await update($, flow, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
597 }
598 })
599
600 on('agent.spawn', async ($, e, next) => {
601 const started = await next(e)
602 if (started.deny !== undefined) return started
603
604 const at = await $.clock.now()
605 await update($, agents, list => {
606 const round = 1 + list.filter(a => norm(a.description) === norm(e.description) && e.description).length
607 const run: AgentRun = {
608 id: started.agentId ?? e.tool_use_id,
609 agentId: started.agentId,
610 type: e.subagentType,
611 description: e.description,
612 model: started.model,
613 status: 'running',
614 startedAt: at,
615 contextTokens: 0,
616 contextMax: windowOf(started.model),
617 tokens: 0,
618 costUsd: 0,
619 steps: 0,
620 round,
621 }
622 return [...list.filter(a => a.id !== run.id), run].slice(-200)
623 })
624 await update($, now, () => at)
625 if (e.subagentType.startsWith('savvy-')) {
626 const f = await read($, flow)
627 await autoOpen($, f && !f.isFinished ? f.title : 'savvy-flow')
628 }
629 return started
630 })
631
632 // Each model request of a subagent: live context, tokens and cost.
633 on('turn.step', async function* ($, e, next) {
634 const result = yield* next(e)
635 const agentId = e.agentId
636 const usage = result.usage
637 if (!usage) return result
638
639 const model = usage.model || e.model
640 if (!agentId) {
641 await update($, main, m => ({
642 ...m,
643 model,
644 effort: typeof e.effort === 'string' ? e.effort : m.effort,
645 contextTokens: usageTokens(usage),
646 contextMax: windowOf(model),
647 tokens: m.tokens + usageTokens(usage),
648 costUsd: m.costUsd + costOf(model, usage),
649 steps: m.steps + 1,
650 }))
651 return result
652 }
653 await update($, agents, list =>
654 list.map(a =>
655 a.agentId !== agentId
656 ? a
657 : {
658 ...a,
659 model,
660 effort: typeof e.effort === 'string' ? e.effort : a.effort,
661 status: 'running' as const,
662 endedAt: undefined,
663 contextTokens:
664 (usage.input_tokens || 0) +
665 (usage.cache_read_input_tokens || 0) +
666 (usage.cache_creation_input_tokens || 0) +
667 (usage.output_tokens || 0),
668 contextMax: windowOf(model),
669 tokens:
670 a.tokens +
671 (usage.input_tokens || 0) +
672 (usage.output_tokens || 0) +
673 (usage.cache_read_input_tokens || 0) +
674 (usage.cache_creation_input_tokens || 0),
675 costUsd: a.costUsd + costOf(model, usage),
676 steps: a.steps + 1,
677 },
678 ),
679 )
680 return result
681 })
682
683 // The main loop's turns: what it works on, and for how long.
684 on('turn.start', async ($, e, next) => {
685 const at = await $.clock.now()
686 const task = e.text.replace(/\s+/g, ' ').trim()
687 await update($, main, m => ({ ...m, status: 'working' as const, turnStartedAt: at, task: task ? clip(task, 80) : m.task }))
688 await update($, now, () => at)
689 return next(e)
690 })
691
692 on('turn.complete', async ($, e, next) => {
693 const agentId = e.agentId
694 if (!agentId) {
695 const at = await $.clock.now()
696 await update($, main, m => ({
697 ...m,
698 status: 'idle' as const,
699 turnStartedAt: undefined,
700 activeMs: m.activeMs + (e.durationMs || (m.turnStartedAt ? at - m.turnStartedAt : 0)),
701 turns: m.turns + 1,
702 }))
703 await update($, now, () => at)
704 }
705 if (agentId) {
706 const at = await $.clock.now()
707 await update($, agents, list =>
708 list.map(a => {
709 if (a.agentId !== agentId) return a
710 // A run whose steps went unseen still gets the turn's own sum.
711 const fallback = a.steps === 0 && e.usage
712 return {
713 ...a,
714 status: (e.reason === 'answer' ? 'done' : 'failed') as AgentRun['status'],
715 endedAt: at,
716 ...(fallback && e.usage
717 ? {
718 model: e.usage.model || a.model,
719 tokens: usageTokens(e.usage),
720 costUsd: costOf(e.usage.model || a.model, e.usage),
721 }
722 : {}),
723 }
724 }),
725 )
726 await update($, now, () => at)
727 }
728 return next(e)
729 })
730 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
731 const s = tr()
732 const ui = $.ui.resolve(e)
733 const { Box, Text, Button } = ui
734 const list = await read($, agents)
735 const f = await read($, flow)
736 const p: Panel = await read($, panel)
737 const m = await read($, main)
738 const at = Math.max(await read($, now), ...list.map(a => a.startedAt), m.turnStartedAt ?? 0, 0)
739
740 const running = list.filter(a => a.status === 'running').reverse()
741 const finished = list.filter(a => a.status !== 'running').reverse()
742 const planned = plannedOf(f, list)
743 const t = totals(list, at, m)
744 const title = f && !f.isFinished ? f.title : ''
745
746 const toggleCompact = (
747 <Button
748 key="compact"
749 label={p.isCompact ? s.expand : s.collapse}
750 plain
751 onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
752 />
753 )
754 const parts = (
755 <Box key="parts" flexDirection="column">
756 <Text dimColor wrap="truncate-end">
757 {s.main}: {partLine(t.main)}
758 </Text>
759 <Text dimColor wrap="truncate-end">
760 {s.subagents}: {partLine(t.subs)}
761 </Text>
762 <Text bold wrap="truncate-end">
763 {s.total}: {partLine(t)}
764 </Text>
765 </Box>
766 )
767
768 if (e.surface === 'desktop' && 'Svg' in ui) {
769 const { Svg } = ui
770 const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
771 const score = (
772 <Svg
773 key="score"
774 source={scoreSvg(W, { main: fmtPart(t.main), subs: fmtPart(t.subs), total: fmtPart(t) }, words(), familyOf(m.model))}
775 alt={`${s.main}: ${partLine(t.main)}. ${s.subagents}: ${partLine(t.subs)}. ${s.total}: ${partLine(t)}`}
776 width={W}
777 height={SCORE_H}
778 />
779 )
780 if (p.isCompact) {
781 return (
782 <Box flexDirection="column" gap={1}>
783 {title ? <Text bold>{title}</Text> : null}
784 {score}
785 {toggleCompact}
786 </Box>
787 )
788 }
789 const drawn = officeSvg(W, {
790 boss: {
791 status: m.status,
792 task: m.task,
793 family: familyOf(m.model),
794 model: m.model ? modelLabel(m.model, m.effort) : '—',
795 ctx: ctxOf(m),
796 },
797 desks: desksOf(list, planned),
798 words: words(),
799 })
800 const alt =
801 `${s.main}: ${m.status === 'working' ? s.isRunning : s.idle}. ` +
802 `${running.length} ${s.isRunning}, ${finished.length} ${s.isFinished}, ${planned.length} ${s.isPlanned}.`
803 return (
804 <Box flexDirection="column" gap={1}>
805 {title ? <Text bold>{title}</Text> : null}
806 {score}
807 <Svg source={drawn.svg} alt={alt} width={W} height={drawn.height} />
808 {toggleCompact}
809 </Box>
810 )
811 }
812
813 // Terminal: the same office as text rows.
814 const cols = Math.max(24, e.props.bodyColumns || 40)
815 const barW = Math.max(6, Math.min(20, cols - 34))
816 const toggleDone = (
817 <Button
818 key="done"
819 label={`${p.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
820 plain
821 onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
822 />
823 )
824 const row = (a: AgentRun) => {
825 const color = colorOfFamily(familyOf(a.model))
826 const ctx = ctxOf(a)
827 const progress = progressOf(a)
828 const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' ' + a.stepNote : ''} · ` : ''
829 return (
830 <Box key={a.id} flexDirection="column" marginBottom={1}>
831 <Box flexDirection="row" gap={1}>
832 <Text color={color}>▣</Text>
833 <Text bold wrap="truncate-end">
834 {a.description || a.type}
835 </Text>
836 <Text color={a.status === 'failed' ? 'red' : a.status === 'done' ? 'green' : color}>{STATUS_GLYPH[a.status]}</Text>
837 </Box>
838 <Text dimColor wrap="truncate-end">
839 {' '}
840 {a.model ? modelLabel(a.model, a.effort) : a.type}
841 {a.round > 1 ? ` · ${s.round} ${a.round}` : ''}
842 </Text>
843 <Text wrap="truncate-end">
844 {' '}
845 {progress === null ? <Text dimColor>{ctxBar(ctx, barW)}</Text> : <Text color={color}>{ctxBar(progress * 100, barW)}</Text>}
846 <Text dimColor>
847 {' '}
848 {steps}ctx {ctx}% · {fmtTokens(a.contextTokens)} ≈{fmtCost(a.costUsd)} {fmtTime(elapsed(a, at))}
849 </Text>
850 </Text>
851 </Box>
852 )
853 }
854 const mainColor = colorOfFamily(familyOf(m.model))
855
856 return (
857 <Box flexDirection="column">
858 <Box flexDirection="row" justifyContent="space-between">
859 <Text bold wrap="truncate-end">
860 {title}
861 </Text>
862 {toggleCompact}
863 </Box>
864 {parts}
865 {p.isCompact ? (
866 <Text wrap="truncate-end">
867 <Text color={mainColor}>{m.status === 'working' ? '●' : '○'} </Text>
868 {[...running, ...finished].map(a => (
869 <Text key={a.id} color={colorOfFamily(familyOf(a.model))}>
870 {STATUS_GLYPH[a.status]}{' '}
871 </Text>
872 ))}
873 {planned.map(pl => (
874 <Text key={`plan-${pl.n}`} dimColor>
875 ◷{' '}
876 </Text>
877 ))}
878 </Text>
879 ) : (
880 <Box flexDirection="column" marginTop={1}>
881 <Box flexDirection="column" marginBottom={1}>
882 <Box flexDirection="row" gap={1}>
883 <Text color={mainColor}>▣</Text>
884 <Text bold>{s.main}</Text>
885 <Text color={mainColor}>{m.status === 'working' ? '●' : '○'}</Text>
886 </Box>
887 <Text dimColor wrap="truncate-end">
888 {' '}
889 {m.status === 'working' ? m.task || '…' : s.idle}
890 </Text>
891 <Text wrap="truncate-end">
892 {' '}
893 <Text dimColor>{ctxBar(ctxOf(m), barW)}</Text>
894 <Text dimColor>
895 {' '}
896 {m.model ? modelLabel(m.model, m.effort) + ' · ' : ''}ctx {ctxOf(m)}%
897 </Text>
898 </Text>
899 </Box>
900 {running.length > 0 && <Text dimColor>{s.running} · {running.length}</Text>}
901 {running.map(row)}
902 {finished.length > 0 && toggleDone}
903 {!p.isDoneCollapsed && finished.map(row)}
904 {planned.length > 0 && <Text dimColor>{s.planned} · {planned.length}</Text>}
905 {planned.map(pl => (
906 <Box key={`plan-${pl.n}`} flexDirection="column" marginBottom={1}>
907 <Text dimColor wrap="truncate-end">
908 <Text color={colorOfFamily(tierFamily(pl.tier))}>▢</Text> {pl.n}. {pl.title} ◷
909 </Text>
910 <Text dimColor wrap="truncate-end">
911 {' '}
912 {TIER_MODEL[pl.tier] ?? pl.tier}
913 {pl.after.length ? ` · ${s.after} ${pl.after.join(', ')}` : ''}
914 </Text>
915 </Box>
916 ))}
917 </Box>
918 )}
919 </Box>
920 )
921 })
922
923 // The band above the prompt: the flow when one runs, else the main session
924 // once it has spent anything.
925 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
926 const f = await read($, flow)
927 const m = await read($, main)
928 const p: Panel = await read($, panel)
929 if (e.props.hasSurvey || p.isBandHidden || (f === null && !hasMain(m))) return next(e)
930
931 const s = tr()
932 const ui = $.ui.resolve(e)
933 const { Box, Text, Button } = ui
934 const list = await read($, agents)
935 const at = Math.max(await read($, now), m.turnStartedAt ?? 0)
936 const t = totals(list, at, m)
937 const crew = list.length + plannedOf(f, list).length
938 const isWorking = m.status === 'working' || list.some(a => a.status === 'running')
939 const band: Band = f
940 ? { title: f.title, label: label(f), progress: ratio(f), cost: `≈${fmtCost(t.cost)}`, isWorking, isFinished: f.isFinished, family: 'opus' }
941 : { title: s.main, label: mainLabel(m), progress: null, cost: `≈${fmtCost(t.cost)}`, isWorking, isFinished: false, family: familyOf(m.model) }
942 const officeButton = (
943 <Button key="savvy-agents" label={crew ? `${s.pane} ×${crew}` : s.pane} plain onPress={() => void togglePane($)} />
944 )
945 const dismiss = (
946 <Button
947 key="savvy-dismiss"
948 label="✕"
949 plain
950 role="dismiss"
951 onPress={() => update($, panel, prev => ({ ...prev, isBandHidden: true }))}
952 />
953 )
954
955 if ('Svg' in ui) {
956 const { Svg } = ui
957 // About 8 CSS px per reported column; the rest is the office button, the
958 // dismiss and their gaps. No floor above the slot: a row wider would wrap.
959 const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 170))
960 return (
961 <Box flexDirection="row" alignItems="center" gap={1}>
962 <Svg source={bandSvg(width, band)} alt={`${band.title}: ${band.label}, ${band.cost}`} width={width} height={BAND_H} />
963 {officeButton}
964 {dismiss}
965 </Box>
966 )
967 }
968
969 const cols = e.props.bodyColumns
970 const titleW = Math.max(8, Math.min(30, band.title.length + 2, Math.floor(cols / 3)))
971 const width = Math.max(6, Math.min(40, cols - titleW - 40))
972 const color = band.isFinished ? DONE : colorOfFamily(band.family)
973 return (
974 <Box flexDirection="row" gap={2}>
975 <Box width={titleW} flexShrink={0}>
976 <Text color={color}>{isWorking ? '● ' : '○ '}</Text>
977 <Text wrap="truncate-end">{band.title}</Text>
978 </Box>
979 {f ? <Text color={color}>{barText(f, width)}</Text> : null}
980 <Text bold>{band.label}</Text>
981 <Text dimColor>{band.cost}</Text>
982 {officeButton}
983 {dismiss}
984 </Box>
985 )
986 })
987}
988hooks/office.ts 397 lines1// The agents panel as an office seen from above: one SVG, pixel sprites on a
2// 2px grid, text in the system font. Pure: data in, markup out.
3
4export type DeskStatus = 'running' | 'done' | 'failed' | 'planned'
5
6export type Desk = {
7 key: string
8 title: string
9 /** `opus`, `sonnet`, `haiku`, `fable` or `other`: the chair's color. */
10 family: string
11 /** The badge: `Opus 4.6 · high`, or what a planned task's tier runs on. */
12 model: string
13 status: DeskStatus
14 /** 0..1 when the worker reports steps; null draws no bar. */
15 progress: number | null
16 /** `3/5 · writing tests` */
17 steps: string
18 /** A planned task's number and what it waits for. */
19 n?: number
20 after?: number[]
21}
22
23export type Boss = {
24 status: 'working' | 'idle'
25 task: string
26 family: string
27 model: string
28 /** Context fill, 0..100. */
29 ctx: number
30}
31
32export type Part = { cost: string; tokens: string; time: string }
33
34export type Words = {
35 main: string
36 subagents: string
37 total: string
38 cost: string
39 tokens: string
40 time: string
41 idle: string
42 pantry: string
43 floor: string
44 after: string
45 /** Short names for the score's tiles. */
46 mainShort: string
47 subsShort: string
48}
49
50export type Office = {
51 boss: Boss
52 desks: Desk[]
53 words: Words
54 /** `light` or `dark` pins one palette (previews); `auto` follows the system. */
55 theme?: 'auto' | 'light' | 'dark'
56}
57
58const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
59
60export const FAMILY_COLOR: Record<string, string> = {
61 opus: '#8f8cf4',
62 sonnet: '#4f9de0',
63 haiku: '#3fb58a',
64 fable: '#e0a33a',
65 other: '#9a9a96',
66}
67
68export const colorOfFamily = (family: string): string => FAMILY_COLOR[family] ?? '#9a9a96'
69
70export const familyOf = (model: string): string => {
71 const m = /(fable|mythos|opus|sonnet|haiku)/i.exec(model)
72 const f = (m?.[1] ?? '').toLowerCase()
73 return f === 'mythos' ? 'fable' : f || 'other'
74}
75
76const xml = (s: string): string =>
77 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
78
79const charEm = (ch: string): number =>
80 /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
81
82export const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
83
84export const fitText = (s: string, size: number, maxW: number): string => {
85 if (textWidth(s, size) <= maxW) return s
86 let out = ''
87 for (const ch of s) {
88 if (textWidth(out + ch + '…', size) > maxW) break
89 out += ch
90 }
91 return out + '…'
92}
93
94// ---------------------------------------------------------------------------
95// Palette: classes, so one drawing serves both themes.
96
97const LIGHT = `.fl{fill:#f4f2ed}.gr{fill:#ebe8e1}.wl{fill:#e3dfd6}.wn{fill:#d6e9f5}.dk{fill:#dcc9a8}.dke{fill:#c7b28d}
98.mo{fill:#2b2b2e}.kb{fill:#c9c6bf}.sk{fill:#e9b48f}.hr{fill:#5a4636}.sh{fill:#e7e4dd}.pl{fill:#cfccc4}
99.bl{fill:#ffffff;stroke:#dcd9d1}.bd{fill:#ffffff;stroke:#dcd9d1}.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#a3a39e}
100.tr{fill:#e6e3dc}.cp{fill:#ebe5da}.ct{fill:#d5ccbc}.lf{fill:#6aa56f}.pt{fill:#c8b8a0}.ds{stroke:#c9c5bc}.tile{fill:#f4f3f0}`
101
102const DARK = `.fl{fill:#1e1e1d}.gr{fill:#232322}.wl{fill:#2a2a28}.wn{fill:#2c3a44}.dk{fill:#4d4236}.dke{fill:#3c342b}
103.mo{fill:#0f0f10}.kb{fill:#5b5853}.sk{fill:#d9a07b}.hr{fill:#3b2e24}.sh{fill:#3a3936}.pl{fill:#4a4844}
104.bl{fill:#2c2c2b;stroke:#3d3d3b}.bd{fill:#262625;stroke:#3a3a38}.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#6f6f6b}
105.tr{fill:#2e2e2c}.cp{fill:#262421}.ct{fill:#3a342c}.lf{fill:#4f8a55}.pt{fill:#5a4c3c}.ds{stroke:#4a4844}.tile{fill:#262625}`
106
107const MOTION = `.on .sc{animation:sc 2.4s steps(2) infinite}@keyframes sc{50%{opacity:.55}}
108.on .hl{animation:ty .36s steps(1) infinite}.on .hr2{animation:ty .36s steps(1) infinite -.18s}
109@keyframes ty{50%{transform:translateY(-2px)}}
110.live{animation:lv 1.6s ease-in-out infinite}@keyframes lv{50%{opacity:.3}}
111.sip{animation:sp 3s steps(1) infinite}@keyframes sp{80%{transform:translateY(-2px)}}
112@media (prefers-reduced-motion: reduce){.on .sc,.on .hl,.on .hr2,.live,.sip{animation:none}}`
113
114const style = (theme: Office['theme']): string => {
115 if (theme === 'light') return `<style>${LIGHT}${MOTION}</style>`
116 if (theme === 'dark') return `<style>${DARK}${MOTION}</style>`
117 return `<style>${LIGHT}@media (prefers-color-scheme: dark){${DARK}}${MOTION}</style>`
118}
119
120// ---------------------------------------------------------------------------
121// Sprites. `r` draws a rect on the 2px grid from (x, y) in grid units.
122
123const P = 2
124const rect = (x: number, y: number, w: number, h: number, cls: string, extra = ''): string =>
125 `<rect x="${x}" y="${y}" width="${w}" height="${h}" class="${cls}"${extra}/>`
126const rectFill = (x: number, y: number, w: number, h: number, fill: string, extra = ''): string =>
127 `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"${extra}/>`
128
129/** Someone seated, seen from above, facing up at the desk; hands on the keys when typing. */
130const seated = (cx: number, top: number, chair: string, isTyping: boolean): string => {
131 const x = cx - 6 * P
132 return `<g>
133${rectFill(x - P, top + 3 * P, 14 * P, 10 * P, chair, ' rx="3"')}
134${rectFill(x - P, top + 11 * P, 14 * P, 2 * P, chair, ' opacity=".7" rx="1"')}
135${rect(x + P, top + 5 * P, 10 * P, 5 * P, 'sh', ' rx="2"')}
136${rect(x + 3 * P, top + 1 * P, 6 * P, 5 * P, 'hr', ' rx="3"')}
137${isTyping ? `<g class="hl">${rect(x + P, top - P, 2 * P, 2 * P, 'sk')}</g><g class="hr2">${rect(x + 9 * P, top - P, 2 * P, 2 * P, 'sk')}</g>` : rect(x + 0, top + 6 * P, 2 * P, 3 * P, 'sk') + rect(x + 10 * P, top + 6 * P, 2 * P, 3 * P, 'sk')}
138</g>`
139}
140
141/** Someone standing with a cup, seen from above. */
142const standing = (cx: number, top: number, shirt: string): string => {
143 const x = cx - 5 * P
144 return `<g class="sip">
145${rectFill(x, top + 4 * P, 10 * P, 5 * P, shirt, ' rx="2"')}
146${rect(x + 2 * P, top, 6 * P, 5 * P, 'hr', ' rx="3"')}
147${rect(x + 9 * P, top + 5 * P, 3 * P, 3 * P, 'bl', ' rx="1"')}
148</g>`
149}
150
151/** A desk: top, monitor (lit when someone works), keyboard, a mug. */
152const desk = (cx: number, top: number, w: number, isLit: boolean, isDim: boolean, monitors = 1): string => {
153 const x = cx - w / 2
154 const mw = 13 * P
155 const screens = Array.from({ length: monitors }, (_, i) => {
156 const mx = cx - (monitors * mw + (monitors - 1) * 3 * P) / 2 + i * (mw + 3 * P)
157 return `${rect(mx, top + P, mw, 3 * P, 'mo', ' rx="1"')}${isLit ? `<rect class="sc" x="${mx + P}" y="${top + 4 * P}" width="${mw - 2 * P}" height="${P}" fill="#8fd3ff"/>` : ''}`
158 }).join('')
159 return `<g${isDim ? ' opacity=".55"' : ''}>
160${rect(x, top, w, 11 * P, 'dk', ' rx="2"')}
161${rect(x, top + 10 * P, w, P, 'dke')}
162${screens}
163${rect(cx - 5 * P, top + 6 * P, 10 * P, 2 * P, 'kb', ' rx="1"')}
164${rect(x + w - 6 * P, top + 5 * P, 3 * P, 3 * P, 'bl', ' rx="2"')}
165</g>`
166}
167
168const plant = (x: number, y: number): string =>
169 `${rect(x, y + 6 * P, 6 * P, 4 * P, 'pt', ' rx="1"')}${rect(x - P, y, 3 * P, 4 * P, 'lf', ' rx="2"')}${rect(x + 2 * P, y - P, 3 * P, 6 * P, 'lf', ' rx="2"')}${rect(x + 4 * P, y + P, 3 * P, 4 * P, 'lf', ' rx="2"')}`
170
171const statusMark = (x: number, y: number, status: DeskStatus, color: string): string => {
172 if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
173 if (status === 'done')
174 return `<circle cx="${x}" cy="${y}" r="7" fill="#3B9C5F"/><path d="M${x - 3.2} ${y}l2.2 2.2 4.2-4.6" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`
175 if (status === 'failed')
176 return `<circle cx="${x}" cy="${y}" r="7" fill="#D0453F"/><path d="M${x - 2.6} ${y - 2.6}l5.2 5.2M${x + 2.6} ${y - 2.6}l-5.2 5.2" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/>`
177 return ''
178}
179
180/** A speech balloon over (cx, bottom) with a line of text and an optional thin bar. */
181const balloon = (cx: number, bottom: number, w: number, text: string, progress: number | null, color: string): string => {
182 const h = progress === null ? 20 : 24
183 const x = Math.round(cx - w / 2)
184 const y = bottom - h - 5
185 const bar =
186 progress === null
187 ? ''
188 : `<rect class="tr" x="${x + 7}" y="${y + h - 7}" width="${w - 14}" height="2.5" rx="1.25"/><rect x="${x + 7}" y="${y + h - 7}" width="${Math.round((w - 14) * progress)}" height="2.5" rx="1.25" fill="${color}"/>`
189 return `<g>
190<rect class="bl" x="${x}" y="${y}" width="${w}" height="${h}" rx="6" stroke-width="1"/>
191<path class="bl" d="M${cx - 4} ${y + h - 0.5}l4 5 4-5" stroke-width="1"/>
192<rect class="bl" x="${cx - 5}" y="${y + h - 2}" width="10" height="2" stroke="none"/>
193<text class="t" x="${x + 7}" y="${y + 13}" font-family="${FONT}" font-size="11">${xml(fitText(text, 11, w - 14))}</text>
194${bar}
195</g>`
196}
197
198const badge = (x: number, y: number, text: string, color: string, maxW: number): string => {
199 const label = fitText(text, 10, maxW - 14)
200 return `<g><circle cx="${x + 3}" cy="${y - 3.5}" r="3" fill="${color}"/><text class="s" x="${x + 10}" y="${y}" font-family="${FONT}" font-size="10">${xml(label)}</text></g>`
201}
202
203// ---------------------------------------------------------------------------
204// Layout.
205
206const PAD = 12
207const WALL = 14
208const CELL_W = 150
209const CELL_H = 128
210const ROWS_PER_FLOOR = 2
211
212const floorTiles = (y: number, W: number, h: number): string => {
213 const out = [rect(0, y, W, h, 'fl')]
214 for (let gx = 24; gx < W; gx += 24) out.push(rect(gx, y, 1, h, 'gr'))
215 for (let gy = y + 24; gy < y + h; gy += 24) out.push(rect(0, gy, W, 1, 'gr'))
216 return out.join('')
217}
218
219const wall = (y: number, W: number): string => {
220 const out = [rect(0, y, W, WALL, 'wl')]
221 for (let x = 40; x + 46 < W; x += 120) out.push(rect(x, y + 4, 46, 6, 'wn', ' rx="1"'))
222 return out.join('')
223}
224
225const bossZone = (x: number, y: number, w: number, o: Office): string => {
226 const b = o.boss
227 const color = colorOfFamily(b.family)
228 const cx = x + w / 2
229 const isWorking = b.status === 'working'
230 const deskTop = y + 34
231 const label = isWorking ? b.task || '…' : o.words.idle
232 return `<g class="${isWorking ? 'on' : ''}">
233${rect(cx - 70, y + 26, 140, 66, 'cp', ' rx="6" opacity=".6"')}
234${plant(cx + 76, deskTop + 4)}
235${desk(cx, deskTop, 112, isWorking, false, 2)}
236${seated(cx, deskTop + 12 * P, color, isWorking)}
237${isWorking ? balloon(cx, deskTop - 2, Math.min(w - 8, 240), label, null, color) : `<text class="m" x="${cx}" y="${deskTop - 8}" text-anchor="middle" font-family="${FONT}" font-size="11">${xml(label)}</text>`}
238<text class="t" x="${cx}" y="${deskTop + 33 * P + 2}" text-anchor="middle" font-family="${FONT}" font-size="11.5" font-weight="600">${xml(o.words.main)}</text>
239${badge(cx - Math.min(textWidth(b.model, 10) + 14, w) / 2, deskTop + 33 * P + 16, `${b.model} · ctx ${b.ctx}%`, color, w)}
240${statusMark(cx + 62, deskTop + 2, isWorking ? 'running' : 'planned', color)}
241</g>`
242}
243
244const deskCell = (x: number, y: number, w: number, d: Desk, words: Words): string => {
245 const color = colorOfFamily(d.family)
246 const cx = x + w / 2
247 const deskTop = y + 34
248 const isWorking = d.status === 'running'
249 const isPlanned = d.status === 'planned'
250 const title = isPlanned && d.n ? `${d.n}. ${d.title}` : d.title
251 const top = isWorking
252 ? balloon(cx, deskTop - 2, w - 16, d.steps ? `${d.title} · ${d.steps}` : d.title, d.progress, color)
253 : ''
254 // An empty chair waits at a desk nobody sits at.
255 const chair = isWorking
256 ? seated(cx, deskTop + 12 * P, color, true)
257 : `${rectFill(cx - 7 * P, deskTop + 15 * P, 14 * P, 10 * P, color, ` rx="3" opacity="${isPlanned ? 0.35 : 0.8}"`)}${rectFill(cx - 7 * P, deskTop + 23 * P, 14 * P, 2 * P, color, ` rx="1" opacity="${isPlanned ? 0.25 : 0.55}"`)}`
258 const sub = isPlanned
259 ? [d.model, d.after?.length ? `${words.after} ${d.after.join(', ')}` : ''].filter(Boolean).join(' · ')
260 : d.model
261 return `<g class="${isWorking ? 'on' : ''}">
262${isPlanned ? `<rect class="ds" x="${cx - 38}" y="${deskTop - 3}" width="76" height="${11 * P + 6}" rx="4" fill="none" stroke-dasharray="3 3"/>` : ''}
263${desk(cx, deskTop, 64, isWorking, isPlanned)}
264${chair}
265${top}
266${statusMark(cx + 30, deskTop + 1, d.status, color)}
267<text class="${isPlanned ? 's' : 't'}" x="${cx}" y="${deskTop + 33 * P + 2}" text-anchor="middle" font-family="${FONT}" font-size="11"${isPlanned ? '' : ' font-weight="600"'}>${xml(fitText(title, 11, w - 12))}</text>
268${badge(cx - Math.min(textWidth(sub, 10) + 14, w - 12) / 2, deskTop + 33 * P + 16, sub, color, w - 12)}
269</g>`
270}
271
272const pantry = (y: number, W: number, people: Desk[], o: Office): string => {
273 const h = 58
274 const max = Math.max(1, Math.floor((W - 150) / 30))
275 const shown = people.slice(0, max)
276 const more = people.length - shown.length
277 return `<g>
278${rect(0, y, W, h, 'cp')}
279${rect(PAD, y + 10, 56, 14, 'ct', ' rx="2"')}${rect(PAD + 6, y + 6, 12, 12, 'mo', ' rx="2"')}${rect(PAD + 26, y + 9, 8, 8, 'bl', ' rx="2"')}
280<circle class="ct" cx="${PAD + 100}" cy="${y + 30}" r="13"/>
281<text class="m" x="${PAD}" y="${y + h - 8}" font-family="${FONT}" font-size="10">${xml(o.words.pantry)}</text>
282${shown.map((p, i) => standing(PAD + 136 + i * 30, y + 14, colorOfFamily(p.family))).join('')}
283${more > 0 ? `<text class="s" x="${PAD + 136 + shown.length * 30}" y="${y + 30}" font-family="${FONT}" font-size="11">+${more}</text>` : ''}
284</g>`
285}
286
287export type Drawn = { svg: string; height: number }
288
289/** The whole office at width W; its height follows the number of desks. */
290export const officeSvg = (W: number, o: Office): Drawn => {
291 const cols = Math.max(1, Math.floor((W - 2 * PAD) / CELL_W))
292 const cellW = (W - 2 * PAD) / cols
293 const perFloor = cols * ROWS_PER_FLOOR
294 const floors: Desk[][] = []
295 for (let i = 0; i < o.desks.length; i += perFloor) floors.push(o.desks.slice(i, i + perFloor))
296
297 const parts: string[] = []
298 let y = 0
299 // Ground floor: the wall and the boss.
300 const bossH = 138
301 parts.push(floorTiles(0, W, WALL + bossH))
302 parts.push(wall(0, W))
303 parts.push(bossZone(PAD, WALL + 4, W - 2 * PAD, o))
304 y = WALL + bossH
305
306 floors.forEach((list, f) => {
307 const rows = Math.ceil(list.length / cols)
308 const h = 22 + rows * CELL_H
309 parts.push(floorTiles(y, W, h))
310 if (f > 0 || floors.length > 1)
311 parts.push(`${rect(0, y, W, 18, 'wl')}<text class="s" x="${PAD}" y="${y + 13}" font-family="${FONT}" font-size="10">${xml(o.words.floor.replace('{n}', String(f + 1)))}</text>`)
312 else parts.push(rect(PAD, y + 6, W - 2 * PAD, 1, 'gr'))
313 list.forEach((d, i) => parts.push(deskCell(PAD + (i % cols) * cellW, y + 18 + Math.floor(i / cols) * CELL_H, cellW, d, o.words)))
314 y += h
315 })
316
317 const done = o.desks.filter(d => d.status === 'done')
318 parts.push(pantry(y, W, done, o))
319 y += 58
320
321 return {
322 height: y,
323 svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${y}" viewBox="0 0 ${W} ${y}" shape-rendering="crispEdges">${style(o.theme)}${parts.join('')}</svg>`,
324 }
325}
326
327// ---------------------------------------------------------------------------
328// The score above the office: one tile per measure, the total large, the main
329// session and the subagents beneath it.
330
331export type Score = { main: Part; subs: Part; total: Part }
332
333export const SCORE_H = 76
334
335export const scoreSvg = (W: number, score: Score, w: Words, mainFamily: string, theme?: Office['theme']): string => {
336 const gap = 6
337 const tw = (W - gap * 2) / 3
338 const tile = (i: number, label: string, k: keyof Part): string => {
339 const x = i * (tw + gap)
340 const line = (y: number, name: string, value: string, dot: string): string => {
341 const v = value.replace(/^≈/, '')
342 const nameW = tw - 25 - textWidth(v, 10) - 6
343 return `<circle cx="${x + 11}" cy="${y - 3.5}" r="2.5" fill="${dot}"/>
344<text class="s" x="${x + 17}" y="${y}" font-family="${FONT}" font-size="10">${xml(fitText(name, 10, Math.max(8, nameW)))}</text>
345<text class="s" x="${x + tw - 8}" y="${y}" text-anchor="end" font-family="${FONT}" font-size="10" font-variant-numeric="tabular-nums">${xml(v)}</text>`
346 }
347 return `<rect class="tile" x="${x}" y="0" width="${tw}" height="${SCORE_H}" rx="8"/>
348<text class="s" x="${x + 9}" y="16" font-family="${FONT}" font-size="11">${xml(fitText(label, 11, tw - 18))}</text>
349<text class="t" x="${x + 9}" y="36" font-family="${FONT}" font-size="16" font-weight="600" font-variant-numeric="tabular-nums">${xml(fitText(score.total[k], 16, tw - 18))}</text>
350${line(55, w.mainShort, score.main[k], colorOfFamily(mainFamily))}
351${line(69, w.subsShort, score.subs[k], colorOfFamily('other'))}`
352 }
353 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${SCORE_H}" viewBox="0 0 ${W} ${SCORE_H}">${style(theme)}
354${tile(0, w.cost, 'cost')}${tile(1, w.tokens, 'tokens')}${tile(2, w.time, 'time')}</svg>`
355}
356
357// ---------------------------------------------------------------------------
358// The band above the prompt: one thin line in the office's colors.
359
360export type Band = {
361 title: string
362 /** `Tasks 3/7`, `Plan`, or the main session's state. */
363 label: string
364 /** 0..1, or null when nothing is planned (main session alone). */
365 progress: number | null
366 cost: string
367 isWorking: boolean
368 isFinished: boolean
369 family: string
370 theme?: 'auto' | 'light' | 'dark'
371}
372
373export const BAND_H = 22
374
375export const bandSvg = (W: number, b: Band): string => {
376 const color = b.isFinished ? '#3B9C5F' : colorOfFamily(b.family)
377 const title = fitText(b.title, 12.5, Math.max(60, W * 0.35))
378 const tx = 18 + textWidth(title, 12.5) + 10
379 const label = fitText(b.label, 11.5, 140)
380 const lx = tx
381 const barX = Math.round(lx + textWidth(label, 11.5) + 12)
382 const costW = textWidth(b.cost, 11.5) + 6
383 const barW = Math.max(0, W - barX - costW - 10)
384 const y = BAND_H / 2
385 const bar =
386 b.progress === null || barW < 40
387 ? ''
388 : `<rect class="tr" x="${barX}" y="${y - 1.5}" width="${barW}" height="3" rx="1.5"/><rect x="${barX}" y="${y - 1.5}" width="${Math.round(barW * b.progress)}" height="3" rx="1.5" fill="${color}"/>`
389 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${BAND_H}" viewBox="0 0 ${W} ${BAND_H}">${style(b.theme)}
390<g class="${b.isWorking ? 'on' : ''}">${rect(1, 6, 10, 7, 'mo', ' rx="1"')}${b.isWorking ? `<rect class="sc" x="2" y="12" width="8" height="2" fill="#8fd3ff"/>` : ''}${rectFill(3, 15, 6, 4, color, ' rx="1"')}</g>
391<text class="t" x="18" y="${y + 4.3}" font-family="${FONT}" font-size="12.5" font-weight="600">${xml(title)}</text>
392<text class="s" x="${lx}" y="${y + 4}" font-family="${FONT}" font-size="11.5" font-variant-numeric="tabular-nums">${xml(label)}</text>
393${bar}
394<text class="s" x="${W - 2}" y="${y + 4}" text-anchor="end" font-family="${FONT}" font-size="11.5" font-variant-numeric="tabular-nums">${xml(b.cost)}</text>
395</svg>`
396}
397types/index.d.ts 80 lines1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4 title: string
5 tier: string
6 after: number[]
7}
8
9export type Flow = {
10 title: string
11 total: number
12 done: number
13 running: number
14 phase: Phase
15 isFinished: boolean
16 tasks: PlannedTask[]
17}
18
19export type AgentStatus = 'running' | 'done' | 'failed'
20
21export type AgentRun = {
22 id: string
23 agentId?: string
24 type: string
25 description: string
26 model: string
27 effort?: string
28 status: AgentStatus
29 startedAt: number
30 endedAt?: number
31 contextTokens: number
32 contextMax: number
33 tokens: number
34 costUsd: number
35 steps: number
36 round: number
37 /** Self-reported by the worker through the `step` tool. */
38 stepDone?: number
39 stepTotal?: number
40 stepNote?: string
41}
42
43/** The main conversation, counted turn by turn as the subagents are. */
44export type MainRun = {
45 model: string
46 effort?: string
47 status: 'working' | 'idle'
48 /** The prompt the current or last turn began with, clipped. */
49 task: string
50 turnStartedAt?: number
51 /** Wall-clock time of finished turns. */
52 activeMs: number
53 tokens: number
54 costUsd: number
55 contextTokens: number
56 contextMax: number
57 steps: number
58 turns: number
59}
60
61export type Panel = {
62 isCompact: boolean
63 isDoneCollapsed: boolean
64 autoOpenedFor: string
65 /** The person dismissed the band; a new flow or opening the office brings it back. */
66 isBandHidden?: boolean
67}
68
69declare module 'claude-code' {
70 interface PluginState {
71 'savvy-progress': {
72 flow: Flow | null
73 agents: AgentRun[]
74 main: MainRun
75 panel: Panel
76 now: number
77 }
78 }
79}
80