SLOPSHOPPER

savvy-progress

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…

newpanebandguardcommandtool
v1.2.0-office.2MITupdated 2026-10-08leolimaferreira/savvy-progress-office
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · savvy-progress
│ ┃ Office ✕ › fix the failing auth test and add an audit log call │ ┃ Collapse │ ┃ Main session: ≈$0.42 · 0 · 0:42 ⏺ Read(src/auth.ts) │ ┃ Subagents: ≈$0.00 · 0 · 0:00 ⎿ Read 6 lines │ ┃ Total: ≈$0.42 · 0 · 0:42 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ▣ Main session ○ ⏺ Bash(bun test) │ ┃ waiting ⎿ 3 pass, 1 fail │ ┃ ██████████░░░░░░░░░░ Opus 5.5 · ctx 49% │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agents-info │ ⎿ savvy-progress: Office opened. │ │ ○ Main session waiting · 0 tokens ≈$0.42 Office ✕ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
○ Main session waiting · 0 tokens ≈$0.42 Office ✕
Pane · Office
Collapse Main session: ≈$0.42 · 0 · 0:42 Subagents: ≈$0.00 · 0 · 0:00 Total: ≈$0.42 · 0 · 0:42 ▣ Main session ○ waiting ██████████░░░░░░░░░░ Opus 5.5 · ctx 49%
README

savvy-progress (office edition)

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

  • Office (/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.
  • Main session counted: tokens, cost, context and active time of the main conversation, not only the subagents.
  • Band above the prompt: title, phase, progress and cost, and a button that opens the office. It shows for the main session alone too.
  • Languages: 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.

Use it locally

Clone it and name the folder in ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/savvy-progress" } }

Use it in cloud sessions

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" } } } }
Source 3 files
hooks/register.tsx 988 lines
1import { 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}
988
hooks/office.ts 397 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
397
types/index.d.ts 80 lines
1export 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