A live dashboard of every running Claude Code session and its subagents: what each is doing now, context, tokens, cost and limits, with little animated…

Every open Claude Code session and its subagents, live: what each is doing, how far along it is, context, cost, limits and prompt-cache time, with a pixel-art colony of the workers.
A Claude Code mod (a hooks-module plugin). It runs in the terminal and in the desktop app's Code tab.
At the prompt of a Claude Code terminal session:
/plugin install fleet --marketplace michaelt604/fleet
Answer y to add the marketplace, then press Enter to take the user scope. Fleet is active at once, and loads in every session you start after that, desktop sessions included.
From a shell:
claude plugin install fleet --marketplace michaelt604/fleet
In the desktop app, where /plugin doesn't run: click + next to the prompt box, then Plugins, Add marketplace with michaelt604/fleet, then Add plugin.
Update: claude plugin marketplace update fleet && claude plugin update fleet@fleet. Open sessions keep the version they loaded until /reload-plugins or a restart.
Uninstall: claude plugin uninstall fleet@fleet. Its heartbeats stay in ~/.claude/fleet/; delete that folder to remove them.
Fleet sees a session only once that session has fleet loaded, so sessions that were already open before the install show up after their /reload-plugins or a restart.
/fleet: opens the dashboard in the app's side panel. Where the surface has no side panel, it draws the dashboard inline in the conversation instead. The panel also opens by itself at the first subagent of a session, unless you closed it./fleet colony: hide or show the colony (shown by default; the panel's "Hide colony" button does the same)./fleet band: a strip of busy sessions above the prompt, for this session./fleet ended: also list ended and closed sessions, and those idle over 12 hours (all hidden by default)./fleet still / /fleet animate: stop or restart all motion./fleet alerts: turn the toasts on or off (on by default). When another session starts needing you, and the panel is open, a toast gives its name and what it waits on ("Aria: Needs your OK: run npm test").⚑ needs you: A, B · 2 working · 5 agents whenever another session is about. Zero counts are left out; with nothing else to say, it shows how many sessions are idle. The flag and the names show only when someone needs you, and lead; names are cut at 16 characters. A session resting between turns while its subagents work counts as working. A run with no surface, title or cost (claude -p, the SDK) is hidden once it ends; a desktop session restored at app start has a title or cost and stays. Rows show text plainly (links as their words, no markup).A session that needs you shows how long it has waited once that is a minute (needs you 6m, and ⚑ needs you: Aria 6m, Bram in the status line), and the longest-waiting leads.
Sessions that need you are listed first. The rest go by their latest prompt, newest first, and don't move when a session starts or stops. A background task's notification doesn't count as a prompt.
Sessions idle for more than 12 hours, with no subagent still at work, are left out too: the desktop app keeps every session you haven't archived loaded, so they never end by themselves. A dim line under the list counts them ("3 sessions idle over 12h hidden · /fleet ended shows them"). The session you're in always shows in its own panel.
Sessions you have archived in the desktop app are left out, even with /fleet ended. They are read from the app's own session records (claude-code-sessions in its data folder), checked about once a minute; unarchive one and it returns.
Open button (unverified): under the list, each other desktop session that needs you gets a button (Aria needs you 6m · Open, pressed with the number key 1 to 9 for its place) that opens the app's link for it (claude://claude.ai/epitaxy/<id>, the id from CLAUDE_CODE_HOST_SESSION_ID, kept only by desktop sessions). I haven't confirmed in the real app that the link brings that session forward. Terminal sessions have no id, so no button.
Each session's main agent is its captain, with a name of its own (Aria, Bram, Cass…), never one a subagent can get. It leads its row in the list ("Hugo: Editing svg.ts") and wears the crown in the colony.
Workers wear a hat for their role, in their role's colour:
| Hat | Role |
|---|---|
| crown | each session's main agent |
| propeller beanie | haiku-quick |
| cap | haiku-worker |
| ranger hat | Explore |
| hard hat | sonnet-worker |
| mortarboard | Plan |
| wizard hat | opus-deep |
| top hat | opus-worker |
| beanie | general-purpose |
Other agent types get a steady hat and colour of their own.
In the colony, a pixel-art village under the header, each building is a general task. A small hanging sign shows it as a picture, with no words. The line over the map says it in words, once, counting everyone and leading with anyone who needs you, in amber: "1 needs you · 2 developing · 1 researching · 3 chilling".
| Building | Sign | Task |
|---|---|---|
| workshop | hammer | Develop: writing and changing code |
| library | book | Research: investigating, reading up, exploring the codebase |
| watchtower | lens | Review: code review, critique, audits |
| forge | flask | Test: running tests, builds, lints, deploys |
| town hall | scroll | Plan: plans and designs, delegating, and waiting on you (with a "!") |
| mine | bug | Debug: digging into bugs and failures |
Each worker holds its task's tool (a hammer, a pickaxe, a book, a lens, a scroll or a flask). Captains wear a gold crown, each in a colour of its own. Most workers also wear a little something of their own, always the same one: shades, round specs, a scarf, a bow tie, rosy cheeks, a mustache, a backpack or a pin. Workers at work have a progress bar over their head and their name right under their feet, in small light letters with no box (over a bare head when a sign is in the way). A name is never put further off, where it would read as a neighbour's: in a crowd with no room, it is left out, those who need you named first. A walker's name shows once it arrives. Workers at play or asleep go unnamed; the line over the map counts them, and the list above names everyone.
Why the colony doesn't jump: the desktop rebuilds the panel's picture on every redraw, which restarts every animation in it. So on the desktop the colony is a Client region (hooks/colony-view-19.tsx; its file name and key carry the release, because the page keeps a module it has loaded even across a plugin reload) that the page keeps across those redraws. It gets plain scene data (hooks/colony-scene.ts) and draws again only when a worker's state changes, timed by the page's own clock. Other surfaces draw it as an ordinary Svg with the panel.
The colony comes first. In the side panel the colony sits right under the header, so it is in view when the panel opens however many sessions there are; the list follows it and scrolls with the panel's own scrollbar.
On a wide panel, the village stays the same size and forest fills either side. A drawing in a page region is held to 100,000 characters, so at most 40 workers are drawn, and an unusually varied crowd leaves out the least busy first; the line over the map still counts everyone. /fleet still stops all motion, and so does the system's reduced-motion setting.
Each session writes ~/.claude/fleet/<session id>.json; the views read those. After /clear the session carries on under a new id with a fresh record (a panel you closed stays closed). A session resumed or restarted under the same id picks up its task, last reply and receipt from its heartbeat.
The header's plan-limit rings combine every session's readings: a window that has already reset is ignored, and of the rest the latest window and highest reading win, so the rings don't jump between sessions. Each ring has two small notches, at 60% (where it turns amber) and 85% (red). Under each ring is the time left to its reset ("5h · 1h12m", or "7d · 6d 21h"); with many limits on a narrow panel the times drop out. Once the readings span 10 minutes or more, the 5-hour ring also forecasts: when the pace of the last half hour would fill the limit before it resets, the line under it reads "5h · full ~40m" in red instead of the reset time (the terminal header and the status line say it too, the status line only under an hour). It is worked out from the app's own recent readings, which are lost on a reload, and nothing is shown for a flat or falling pace. With a plan limit (5h or 7d) the cost reads "API value", not "spent": it's what the same use would cost on the API. Heartbeats older than 7 days are removed when a session starts.
haiku-quick, haiku-worker, sonnet-worker, opus-worker, opus-deep). Any other agent type still gets a steady hat and colour of its own.step tool from an MCP server named savvy-progress (mcp__savvy-progress__step, with done and total). Without one, bars are "~" guesses.~/.claude/fleet/. It reads the desktop app's own session records only to hide archived sessions.git clone https://github.com/michaelt604/fleet.git
claude --plugin-dir ./fleet
--plugin-dir loads the mod from your working copy for that session (uninstall the marketplace copy first, or both run).
claude plugin validate . checks the manifest, the marketplace file and the hooks module the way the engine reads them.claude plugin test . runs tests/*.test.ts(x) against a fake engine..claude-plugin/types/ (ignored by git); then tsc -p ..hooks/register.tsx is the hooks module (events, heartbeats, commands); describe.ts turns tool calls into words; draw.tsx and svg.ts draw the panel, band and status line; colony.ts and colony-scene.ts draw the village; types/index.d.ts is the $.state contract.version in .claude-plugin/plugin.json. When colony drawing changes, also rename hooks/colony-view-NN.tsx and its key in draw.tsx to the new release number (the desktop page keeps a loaded module across reloads), and update the matching import and colony-NN finds in tests/fleet.test.tsx.hooks/register.tsx 981 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FleetAgent, FleetLimit, FleetSession, FleetView } from '../types'
5import { waitText, EDIT_TOOLS, NOT_TYPED, isHostId, isPlanLimit, archiveOf, withFleetLimits, ZERO, currentLimits, forecastFull, fullIn, sameWindow, isFullShown as shownFull, plural, isAgentLive, isBusy, isDormant, pickName, addUsage, arrange, attentionKey, describeTool, firstLine, kindOfTool, nameOf, parseSession, progressOf, summarize, tasksAfter, totalTokens, windowOf } from './describe'
6import { colonyDueAt, noteColonyForced } from './colony-scene'
7import { band, dashboard } from './draw'
8import type { Actions, Board } from './draw'
9import type { LimitSample } from './describe'
10
11const PANE = 'fleet'
12const me = atom({ plugin: 'fleet', key: 'me' } as const, null)
13const sessions = atom({ plugin: 'fleet', key: 'sessions' } as const, [])
14const frame = atom({ plugin: 'fleet', key: 'frame' } as const, 0)
15// The side panel is the fleet's home; the band above the prompt is opt-in (/fleet band).
16const DEFAULT_VIEW: FleetView = { isCompact: false, isStill: false, showEnded: false, showBand: false, showColony: true, showAlerts: true }
17const view = atom({ plugin: 'fleet', key: 'view' } as const, DEFAULT_VIEW)
18
19const FRAME_MS = 400
20const TICK_MS = 2_000
21const SCAN_LIVE_MS = 2_000
22const SCAN_BAND_MS = 5_000
23const SCAN_IDLE_MS = 15_000
24const HEARTBEAT_MS = 20_000
25const USAGE_MS = 5_000
26const TITLE_MS = 5 * 60_000
27const DAY_MS = 86_400_000
28const ARCHIVE_MS = 60_000
29// A drawing counts as on screen while it has drawn this recently.
30const SEEN_MS = 3_000
31// The newest /fleet card stays live this long, then holds still to spare redraws.
32const LIVE_CARD_MS = 10 * 60_000
33
34// Module state: rebuilt on a reload, when session.start runs again.
35let dir = ''
36let home = ''
37let myId = ''
38let isWindows = false
39let isMac = false
40let appDir = ''
41let localAppData = ''
42let lastArchive = 0
43let isPaneOpen = false
44let isWritePending = false
45let lastWrite = 0
46let lastScan = 0
47let lastUsage = 0
48let lastTitle = 0
49let lastSignature = ''
50let lastStatus: string | undefined
51// The last heartbeat each file held that parsed: a file caught mid-write keeps its session showing.
52const lastGood = new Map<string, FleetSession>()
53// The desktop app's own record of each Code session, by path: read again only when its size or time moves.
54const records = new Map<string, { mtimeMs: number; size: number; cli: string; isArchived: boolean }>()
55// Heartbeat ids of sessions the person archived in the desktop app: left out of the fleet.
56let archived = new Set<string>()
57// Each session's state at the last scan, to see who has just started needing the person. The first
58// scan only fills it, so a fleet that was already waiting doesn't toast at startup.
59const lastStates = new Map<string, string>()
60let isSeeded = false
61// Subagents with a permission dialog open, by agent id, oldest first: what each dialog says. Kept
62// apart from the stored record, which goes on tracking the real state; while this holds anything the
63// heartbeat shows the session as waiting on the oldest one. The tool names the call that holds the
64// dialog, so a parallel call of another tool finishing first does not close it.
65const openDialogs = new Map<string, { text: string; tool: string; at: number }>()
66const withDialogs = (s: FleetSession): FleetSession => {
67 const first = openDialogs.values().next().value
68 return first !== undefined && s.state !== 'ended' ? { ...s, state: 'waiting', kind: 'waiting', now: first.text, waitingSince: Math.min(s.waitingSince ?? first.at, first.at) } : s
69}
70const TOAST_MS = 8_000
71const HEARTBEAT_KEEP_MS = 7 * DAY_MS
72// The /fleet card in the transcript that is live: the newest one drawn after a run.
73let isCardPending = false
74let liveCard: string | null = null
75let liveCardAt = 0
76// When a terminal drawing (which animates by frames) last drew: the frame clock runs only then.
77let terminalDrawAt = 0
78const seenCards = new Set<string>()
79
80const blank = (id: string, now: number): FleetSession => ({
81 v: 1,
82 id,
83 project: '',
84 cwd: '',
85 surface: '',
86 model: '',
87 effort: null,
88 state: 'idle',
89 task: '',
90 now: 'Waiting for a prompt',
91 last: '',
92 turnStartedAt: null,
93 idleSince: now,
94 turns: 0,
95 toolsThisTurn: 0,
96 toolsTotal: 0,
97 contextTokens: 0,
98 contextWindow: 0,
99 contextPercent: null,
100 costUsd: null,
101 tokens: ZERO,
102 limits: [],
103 agents: [],
104 startedAt: now,
105 updatedAt: now,
106})
107
108// What the panel was last handed, in outline (element types, keys, labels, short texts), written to
109// `.debug-pane.txt` beside the heartbeats while a `.debug` file is there: the one way to see what a
110// real surface receives, since its drawing can't be watched from here. Checked at most every 10s.
111let debugCheckedAt = 0
112let isDebugging = false
113async function dumpPane($: EngineInterface, surface: string, columns: number, tree: unknown, room = '') {
114 if (dir === '') return
115 const now = await $.clock.now()
116 if (now - debugCheckedAt >= 10_000) {
117 debugCheckedAt = now
118 isDebugging = await $.fs.exists(`${dir}/.debug`).catch(() => false)
119 }
120 if (!isDebugging) return
121 const lines: string[] = []
122 const walk = (node: unknown, depth: number) => {
123 if (node === null || node === undefined || node === false) return
124 if (typeof node === 'string' || typeof node === 'number') return void lines.push(`${' '.repeat(depth)}"${String(node).slice(0, 80)}"`)
125 if (Array.isArray(node)) return void node.forEach(n => walk(n, depth))
126 const el = node as { type?: unknown; props?: Record<string, unknown>; children?: unknown }
127 const p = el.props ?? {}
128 const bits = ['key', 'label', 'module', 'width', 'height', 'alt'].filter(k => p[k] !== undefined).map(k => `${k}=${JSON.stringify(p[k]).slice(0, 80)}`)
129 if (typeof p.source === 'string') bits.push(`source=${p.source.length}ch`)
130 lines.push(`${' '.repeat(depth)}${String(el.type)} ${bits.join(' ')}`)
131 walk(el.children ?? p.children, depth + 1)
132 }
133 walk(tree, 0)
134 await $.fs.write(`${dir}/.debug-pane.txt`, `${new Date(now).toISOString()} surface=${surface} columns=${columns} ${room}\n${lines.join('\n')}\n`).catch(() => undefined)
135}
136
137// Writes go one after another, each reading the record just before it writes, so an older write can
138// never land after a newer one and put back a state that has passed.
139let writing: Promise<void> = Promise.resolve()
140
141function flush($: EngineInterface): Promise<void> {
142 isWritePending = false
143 writing = writing.then(async () => {
144 const at = await $.clock.now()
145 const mine = await read($, me)
146 if (mine === null || dir === '') return
147 lastWrite = at
148 try {
149 await $.fs.write(`${dir}/${mine.id}.json`, JSON.stringify({ ...withDialogs(mine), updatedAt: at }))
150 } catch {
151 // A failed heartbeat shows as a stale one elsewhere; the next write tries again.
152 }
153 })
154 return writing
155}
156
157// Changes this session's own record; writes it out within half a second.
158async function patch($: EngineInterface, fn: (s: FleetSession) => FleetSession) {
159 const now = await $.clock.now()
160 // Stamp when the state turns to waiting, and clear it when it leaves.
161 await update($, me, current => {
162 if (current === null) return current
163 const next = fn(current)
164 if (next.state === 'waiting') return current.state === 'waiting' && current.waitingSince ? { ...next, waitingSince: current.waitingSince } : { ...next, waitingSince: now }
165 return next.waitingSince ? { ...next, waitingSince: null } : next
166 })
167 if (!isWritePending) {
168 isWritePending = true
169 $.clock.after(500, () => void flush($))
170 }
171}
172
173const patchAgent = ($: EngineInterface, agentId: string, fn: (a: FleetAgent) => FleetAgent) =>
174 patch($, s => ({ ...s, agents: s.agents.map(a => (a.id === agentId ? fn(a) : a)) }))
175
176async function refreshUsage($: EngineInterface) {
177 lastUsage = await $.clock.now()
178 try {
179 const [usage, model, surface] = await Promise.all([$.session.usage(), $.session.model(), $.session.surface()])
180 await patch($, s => ({
181 ...s,
182 model: model || s.model,
183 surface: surface ?? s.surface,
184 contextTokens: usage.context.tokens ?? s.contextTokens,
185 contextWindow: usage.context.window,
186 contextPercent: usage.context.percent ?? null,
187 costUsd: usage.cost?.usd ?? s.costUsd,
188 limits: usage.rateLimits.map(limit => ({ kind: limit.kind, percentUsed: limit.percentUsed, resetsAt: limit.resetsAt ?? null })),
189 // Once a reading has held a plan limit, this is a subscription for good (an API key never has one).
190 hasPlan: s.hasPlan || usage.rateLimits.some(isPlanLimit) || undefined,
191 }))
192 } catch {
193 // Usage is a nicety; the heartbeat goes on without it.
194 }
195}
196
197// Claude Code keeps a session's title in its transcript as `custom-title` rows; the last one stands.
198// The transcript can run past what one $.fs.read takes, so a shell tool picks the row out.
199const mangle = (path: string): string => path.replace(/[^a-zA-Z0-9]/g, '-')
200
201async function refreshTitle($: EngineInterface) {
202 lastTitle = await $.clock.now()
203 const mine = await read($, me)
204 if (mine === null || home === '') return
205 for (const folder of [mine.cwd, await $.session.root()]) {
206 const path = `${home}/.claude/projects/${mangle(folder)}/${mine.id}.jsonl`
207 if (!(await $.fs.exists(path).catch(() => false))) continue
208 const argv = isWindows
209 ? [
210 'powershell',
211 '-NoProfile',
212 '-NonInteractive',
213 '-Command',
214 // No double quote or backslash in the command: Windows PowerShell's own command-line
215 // parsing drops or eats them when the command comes as an argument ([char]34 is ", [char]92 is \).
216 // The pattern takes escaped characters (\") inside the title; the output is UTF-8 for any script.
217 `[Console]::OutputEncoding = [Text.Encoding]::UTF8; $q = [char]34; $b = [char]92; ` +
218 `$p = $q + 'customTitle' + $q + ':' + $q + '((?:[^' + $q + $b + $b + ']|' + $b + $b + '.)*)'; ` +
219 `$m = Select-String -LiteralPath '${path.replace(/'/g, "''")}' -Pattern $p | Select-Object -Last 1; if ($m) { $m.Matches[0].Groups[1].Value }`,
220 ]
221 : ['sh', '-c', `grep -o '"customTitle":"\\([^"\\]\\|\\\\.\\)*"' "$1" | tail -n 1 | sed 's/^"customTitle":"//; s/"$//'`, 'sh', path]
222 try {
223 const result = await $.process.run(argv, { timeoutMs: 15_000 })
224 const raw = result.stdout.trim()
225 if (result.exitCode === 0 && raw !== '') {
226 const title = JSON.parse(`"${raw}"`) as string
227 if (title !== mine.title) await patch($, s => ({ ...s, title }))
228 }
229 } catch {
230 // No title yet, or no shell tool: the folder name stands in.
231 }
232 return
233 }
234}
235
236// What a drawing shows of the fleet, so a redraw only happens when that changes (each redraw of a
237// desktop card restarts its CSS motion). Times move once a minute; token counts by the thousand;
238// fields that change on every model step (cache and step times) only through what they show.
239const thousands = (n: number): number => Math.round(n / 1_000)
240const minutes = (at: number | null | undefined): number | null => (at ? Math.floor(at / 60_000) : null)
241const signatureOf = (found: readonly FleetSession[], now: number): string =>
242 `${Math.floor(now / 60_000)}|${JSON.stringify(
243 found.map(s => ({
244 ...s,
245 updatedAt: Math.floor((now - s.updatedAt) / 60_000),
246 cacheAt: minutes(s.cacheAt),
247 toolAt: undefined,
248 tokens: thousands(totalTokens(s.tokens)),
249 contextTokens: thousands(s.contextTokens),
250 costUsd: s.costUsd === null ? null : Math.round(s.costUsd * 100),
251 limits: s.limits.map(l => Math.round(l.percentUsed)),
252 agents: s.agents.map(a => ({ ...a, lastAt: undefined, toolAt: undefined, steps: undefined, tokens: thousands(totalTokens(a.tokens)), contextTokens: thousands(a.contextTokens) })),
253 loops: (s.loops ?? []).map(l => ({ id: l.id, model: l.model, tokens: thousands(l.tokens), lastAt: minutes(l.lastAt) })),
254 attention: attentionKey(s, now),
255 })),
256 )}`
257
258// Reads every session's heartbeat; files untouched for a day are not read.
259// Recent readings of each limit kind, for the 5-hour forecast. Lost on a reload, which is fine.
260const SAMPLE_GAP_MS = 30_000
261const SAMPLE_KEEP_MS = 45 * 60_000
262const limitSamples = new Map<string, LimitSample[]>()
263let isFullShown = false
264
265// Notes the fleet's current limits: one reading per kind per 30 s, a new window starting afresh.
266function sampleLimits(limits: readonly FleetLimit[], now: number) {
267 for (const l of limits) {
268 let list = limitSamples.get(l.kind) ?? []
269 if (list.length > 0 && !sameWindow(list[list.length - 1].resetsAt, l.resetsAt)) list = []
270 const last = list[list.length - 1]
271 if (!last || now - last.at >= SAMPLE_GAP_MS) list.push({ at: now, pct: l.percentUsed, resetsAt: l.resetsAt })
272 limitSamples.set(l.kind, list.filter(p => now - p.at <= SAMPLE_KEEP_MS))
273 }
274}
275
276// The limits with the 5-hour one's forecast, when its pace would fill it before the reset.
277function withForecast(limits: FleetLimit[], now: number): FleetLimit[] {
278 return limits.map(l => {
279 if (l.kind !== 'five_hour') return l
280 const fullInMs = forecastFull(limitSamples.get(l.kind) ?? [], l.resetsAt, now)
281 return fullInMs === null ? l : { ...l, fullInMs }
282 })
283}
284
285// Reads the desktop app's session records (<org>/<account>/local_*.json) about once a minute.
286async function refreshArchived($: EngineInterface) {
287 if (appDir === '' || lastScan - lastArchive < ARCHIVE_MS) return
288 lastArchive = lastScan
289 try {
290 const dirs = async (path: string) => (await $.fs.list(path)).filter(e => e.kind === 'dir').map(e => `${path}/${e.name}`)
291 // The Windows app is a packaged one: a process it didn't start sees its folder only under the package.
292 const orgs = await dirs(appDir).catch(async () => {
293 if (!isWindows || localAppData === '') throw new Error('no app folder')
294 const pkg = (await $.fs.list(`${localAppData}/Packages`)).find(e => e.kind === 'dir' && e.name.startsWith('Claude_'))
295 if (pkg === undefined) throw new Error('no app package')
296 appDir = `${localAppData}/Packages/${pkg.name}/LocalCache/Roaming/Claude/claude-code-sessions`
297 return dirs(appDir)
298 })
299 const accounts = (await Promise.all(orgs.map(dirs))).flat()
300 const files = (await Promise.all(accounts.map(async path => (await $.fs.list(path)).filter(e => e.kind === 'file' && /^local_.*\.json$/.test(e.name) && lastScan - e.mtimeMs < HEARTBEAT_KEEP_MS).map(e => ({ ...e, path: `${path}/${e.name}` })))))
301 .flat()
302 const paths = new Set(files.map(f => f.path))
303 for (const path of [...records.keys()]) if (!paths.has(path)) records.delete(path)
304 await Promise.all(
305 files.map(async f => {
306 const known = records.get(f.path)
307 if (known && known.mtimeMs === f.mtimeMs && known.size === f.size) return
308 const parsed = archiveOf(String(await $.fs.read(f.path).catch(() => '')))
309 if (parsed !== null) records.set(f.path, { mtimeMs: f.mtimeMs, size: f.size, ...parsed })
310 }),
311 )
312 archived = new Set([...records.values()].filter(r => r.isArchived).map(r => r.cli))
313 } catch {
314 // No desktop app folder (terminal only, Linux) or it can't be listed: nothing is left out.
315 }
316}
317
318async function scan($: EngineInterface, isForced = false) {
319 lastScan = await $.clock.now()
320 if (dir === '') return
321 let found: FleetSession[] = []
322 try {
323 const entries = await $.fs.list(dir)
324 const fresh = entries.filter(entry => entry.kind === 'file' && entry.name.endsWith('.json') && lastScan - entry.mtimeMs < DAY_MS)
325 const texts = await Promise.all(fresh.map(entry => $.fs.read(`${dir}/${entry.name}`).catch(() => '')))
326 // A file gone from the folder takes its last good reading with it.
327 const names = new Set(fresh.map(entry => entry.name))
328 for (const name of [...lastGood.keys()]) if (!names.has(name)) lastGood.delete(name)
329 found = fresh
330 .map((entry, i) => {
331 const text = texts[i]
332 const parsed = parseSession(typeof text === 'string' ? text : '')
333 if (parsed !== null) lastGood.set(entry.name, parsed)
334 return parsed ?? lastGood.get(entry.name) ?? null
335 })
336 .filter((s): s is FleetSession => s !== null)
337 } catch {
338 // No folder yet (this session's first heartbeat makes it) or it can't be listed: keep what was
339 // shown, and don't let the next good scan toast every waiting session again.
340 return
341 }
342 await refreshArchived($)
343 // This session stays in its own panel even once archived.
344 found = found.filter(s => !archived.has(s.id) || s.id === myId)
345 const signature = signatureOf(found, lastScan)
346 if (isForced || signature !== lastSignature) {
347 lastSignature = signature
348 await update($, sessions, () => found)
349 }
350
351 const all = arrange(found, lastScan, false, myId || undefined)
352 const sum = summarize(all, lastScan)
353 // Only the other sessions that wait on the person: this one says so itself.
354 const waiting = all.filter(s => s.state === 'waiting' && s.id !== myId)
355 // Another session that has just started needing the person says so, while this panel is open.
356 if (isSeeded && isPaneOpen && (await read($, view)).showAlerts !== false) {
357 for (const s of waiting) if (lastStates.get(s.id) !== 'waiting') $.ui.toast(`${nameOf(s)}: ${firstLine(s.now, 60)}`, { timeoutMs: TOAST_MS })
358 }
359 lastStates.clear()
360 for (const s of all) lastStates.set(s.id, s.state)
361 isSeeded = true
362 // What needs the person leads; names are cut so a long title doesn't push the rest off. The flag shows only then.
363 const isAbout = sum.sessions > 1 || waiting.length > 0
364 const busy = [waiting.length ? `⚑ needs you: ${waiting.map(s => [firstLine(nameOf(s), 16), waitText(s, lastScan)].filter(Boolean).join(' ')).join(', ')}` : '', isAbout && sum.working > 0 ? `${sum.working} working` : '', isAbout && sum.agents > 0 ? plural(sum.agents, 'agent') : ''].filter(Boolean)
365 // With nobody needing you and nothing working, say how many rest.
366 const idle = sum.sessions
367 const limits = currentLimits(all, lastScan)
368 sampleLimits(limits, lastScan)
369 const fullMs = withForecast(limits, lastScan).find(l => l.kind === 'five_hour')?.fullInMs
370 // Shown once under an hour; hidden only above 70 minutes, so it doesn't flicker at the edge.
371 isFullShown = shownFull(isFullShown, fullMs)
372 const full = isFullShown && fullMs !== undefined ? [`5h full ~${fullIn(fullMs)}`] : []
373 const parts = [...(busy.length || !isAbout || idle === 0 ? busy : [`${idle} idle`]), ...full]
374 const status = parts.length ? parts.join(' · ') : undefined
375 if (status !== lastStatus) {
376 lastStatus = status
377 $.ui.status(status)
378 }
379}
380
381// A path inside a PowerShell single-quoted string: its quote marks (U+2018 to U+201B count as one) doubled.
382const quoted = (text: string): string => text.replace(/['\u2018-\u201B]/g, m => m + m)
383
384// Heartbeats of sessions long gone: removed once a session starts, so the folder doesn't grow forever.
385async function pruneHeartbeats($: EngineInterface) {
386 if (dir === '') return
387 const days = HEARTBEAT_KEEP_MS / DAY_MS
388 const argv = isWindows
389 ? [
390 'powershell',
391 '-NoProfile',
392 '-NonInteractive',
393 '-Command',
394 `Get-ChildItem -LiteralPath '${quoted(dir)}' -Filter *.json | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-${days}) } | Remove-Item`,
395 ]
396 : ['sh', '-c', `find "$1" -maxdepth 1 -name '*.json' -mtime +${days} -delete`, 'sh', dir]
397 try {
398 await $.process.run(argv, { timeoutMs: 15_000 })
399 } catch {
400 // Nothing to prune, or no shell tool: the scan skips old files anyway.
401 }
402}
403
404// After /clear (or a resume), the process goes on under a new session id and no session.start runs:
405// follow it, so the new conversation gets its own heartbeat and the old one stays ended.
406async function followSession($: EngineInterface) {
407 const id = await $.session.id()
408 if (id === myId) return
409 const at = await $.clock.now()
410 myId = id
411 // A resumed conversation picks up what its own heartbeat said it was about; a closed panel stays
412 // closed, /clear or not.
413 const earlier = await lastHeartbeat($, id)
414 await update($, me, current =>
415 current === null
416 ? current
417 : { ...blank(id, at), ...earlier, cwd: current.cwd, project: current.project, surface: current.surface, mode: current.mode, hostId: current.hostId, panelClosed: current.panelClosed, panelOpenedOnce: current.panelOpenedOnce, hasPlan: current.hasPlan || earlier.hasPlan },
418 )
419 lastTitle = 0
420 await flush($)
421}
422
423const saveView = async ($: EngineInterface, fn: (v: FleetView) => FleetView) => {
424 const next = await update($, view, fn)
425 if (next !== undefined) await $.store.set('view', next)
426 return next
427}
428
429// Reads only what the drawing shows: never this session's own record, which changes on every
430// tool call, and never the frame, which only the terminal's text workers move by.
431async function board($: EngineInterface): Promise<Board> {
432 const [all, v] = await Promise.all([read($, sessions), read($, view)])
433 const now = await $.clock.now()
434 const keep = myId || undefined
435 // Each row is judged on the whole fleet's plan limits.
436 const list = arrange(all, now, v.showEnded, keep).map(s => withFleetLimits(s, all, now))
437 return {
438 list,
439 resting: v.showEnded ? 0 : arrange(all, now, true, keep).filter(s => s.id !== keep && isDormant(s, now)).length,
440 sum: summarize(list, now),
441 limits: withForecast(currentLimits(all, now), now),
442 v,
443 mineId: myId || undefined,
444 now,
445 }
446}
447
448// Opens the side panel. Where the surface places none, the /fleet command draws the card instead.
449async function openPanel($: EngineInterface) {
450 const opened = await $.ui.open({ id: PANE, title: 'Fleet' })
451 isPaneOpen = opened.isPlaced
452 $.clock.after(1, () => void scan($, true))
453 if (!opened.isPlaced) await $.command.run({ command: 'fleet', args: '' }).catch(() => undefined)
454}
455
456// Brings up the desktop app's window for a session that waits on the person. The link is the app's
457// own deep link; its id comes from another session's file, so it is checked before it reaches a command.
458async function go($: EngineInterface, id: string) {
459 const hostId = (await read($, sessions)).find(s => s.id === id)?.hostId
460 if (hostId === undefined || !isHostId(hostId)) return
461 const link = `claude://claude.ai/epitaxy/${hostId}`
462 const argv = isWindows ? ['rundll32', 'url.dll,FileProtocolHandler', link] : [isMac ? 'open' : 'xdg-open', link]
463 try {
464 await $.process.run(argv, { timeoutMs: 15_000 })
465 } catch {
466 // No way to open the link here: the button does nothing.
467 }
468}
469
470// The panel rescans every 2 seconds while it is open, so it needs no refresh button.
471const actionsFor = ($: EngineInterface): Actions => ({
472 open: () => void openPanel($),
473 hideBand: () => void saveView($, v => ({ ...v, showBand: false })),
474 toggle: key => void saveView($, v => ({ ...v, [key]: !v[key] })),
475 go: id => void go($, id),
476})
477
478// Tools that hold the main loop's call until the person answers, and what the row says meanwhile.
479const ASKS = new Map([
480 ['AskUserQuestion', 'Asking you a question'],
481 ['ExitPlanMode', 'Plan ready for your OK'],
482])
483
484// Permission modes in which an "ask" is settled without the person.
485const SETTLES_ASKS = new Set(['auto', 'bypassPermissions', 'dontAsk'])
486
487// The session waits on the person to approve a tool call. It shows until that call's tool.call
488// finishes: no event marks the approval itself.
489async function markWaiting($: EngineInterface, tool: string, input: unknown) {
490 const what = describeTool(tool, (input ?? {}) as Record<string, unknown>)
491 await patch($, s => ({ ...s, state: 'waiting', now: `Needs your OK: ${what}`, kind: 'waiting' }))
492 await flush($)
493}
494
495// A subagent's dialog names the subagent and marks its own row. The session's stored state is left
496// alone: the heartbeat shows it waiting while any dialog is open (see withDialogs).
497async function markAgentWaiting($: EngineInterface, agent: { id: string; name: string }, tool: string, input: unknown) {
498 const text = `Needs your OK (${agent.name}): ${describeTool(tool, (input ?? {}) as Record<string, unknown>)}`
499 openDialogs.set(agent.id, { text, tool, at: await $.clock.now() })
500 await patchAgent($, agent.id, a => ({ ...a, now: text, kind: 'waiting' }))
501 await flush($)
502}
503
504async function noteMode($: EngineInterface, mode: unknown) {
505 if (typeof mode !== 'string') return
506 const mine = await read($, me)
507 if (mine !== null && mine.mode !== mode) await patch($, s => ({ ...s, mode }))
508}
509
510// What a session's own heartbeat on disk says it was about, for a session starting again under the
511// same id: its task, last answer and receipt, never its running state or its subagents.
512async function lastHeartbeat($: EngineInterface, id: string): Promise<Partial<FleetSession>> {
513 if (dir === '') return {}
514 try {
515 const was = parseSession(await $.fs.read(`${dir}/${id}.json`))
516 if (was === null || was.id !== id) return {}
517 const { task, last, title, tasks, receipt, promptAt, idleSince, turns, panelClosed, panelOpenedOnce, mode, compactions, hostId, cacheAt, hasPlan } = was
518 const kept = { task, last, title, tasks, receipt, promptAt, idleSince, turns, panelClosed, panelOpenedOnce, mode, compactions, hostId, cacheAt, hasPlan }
519 return Object.fromEntries(Object.entries(kept).filter(([, value]) => value !== undefined && value !== null)) as Partial<FleetSession>
520 } catch {
521 return {}
522 }
523}
524
525// The terminal's text workers move by frames; the desktop's SVG ones move by CSS on their own.
526async function frameFor($: EngineInterface, surface: string): Promise<number> {
527 if (surface !== 'terminal') return 0
528 terminalDrawAt = await $.clock.now()
529 return read($, frame)
530}
531
532export const register: Register = on => {
533 on('session.start', async ($, e, next) => {
534 const started = await next(e)
535 home = ((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '').replace(/\\/g, '/')
536 dir = home ? `${home}/.claude/fleet` : ''
537 isWindows = (await $.env.get('OS')) === 'Windows_NT'
538 // The mod runtime names no platform: a home under /Users is a Mac's.
539 isMac = !isWindows && home.startsWith('/Users/')
540 const appData = isWindows ? ((await $.env.get('APPDATA')) || '').replace(/\\/g, '/') : isMac ? `${home}/Library/Application Support` : ''
541 appDir = appData ? `${appData}/Claude/claude-code-sessions` : ''
542 localAppData = isWindows ? ((await $.env.get('LOCALAPPDATA')) || '').replace(/\\/g, '/') : ''
543 lastArchive = 0
544 records.clear()
545 archived = new Set()
546 const [id, cwd, root, surface, at] = await Promise.all([$.session.id(), $.session.cwd(), $.session.root(), $.session.surface(), $.clock.now()])
547 // The desktop app sets this in each session it starts. Children inherit it, so a terminal claude -p
548 // run from a desktop session's shell would claim the parent's id: only a desktop surface keeps it.
549 const hostId = surface === 'desktop' ? (await $.env.get('CLAUDE_CODE_HOST_SESSION_ID')) || undefined : undefined
550 myId = id
551 const stored = (await $.store.get('view')) as Partial<FleetView> | undefined
552 // The band's setting lasts for the session it was set in; each new session starts from the default.
553 await update($, view, () => ({ ...DEFAULT_VIEW, ...stored, showBand: DEFAULT_VIEW.showBand }))
554
555 // A reload keeps the record the session already had. A session started again under the same id
556 // (the app restarted, a resume) picks up what its last heartbeat said it was about, resting.
557 const earlier = await lastHeartbeat($, id)
558 await update($, me, current =>
559 current !== null && current.id === id
560 ? current
561 : { ...blank(id, at), ...earlier, cwd, project: root.split(/[\\/]/).filter(Boolean).at(-1) ?? root, surface: surface ?? '', hostId },
562 )
563 // /fleet first, so it answers as soon as the session is up; the slower work follows on its own.
564 await $.command.register({ name: 'fleet', description: 'Show every running session and its agents, live', argumentHint: '[band|colony|ended|still|animate|alerts]' })
565 $.clock.after(1, () => {
566 void (async () => {
567 await refreshUsage($)
568 await flush($)
569 await scan($, true)
570 await refreshTitle($)
571 await pruneHeartbeats($)
572 })()
573 })
574
575 $.clock.every(FRAME_MS, () => {
576 void (async () => {
577 const now = await $.clock.now()
578 if (now - terminalDrawAt > SEEN_MS) return
579 const v = await read($, view)
580 const all = arrange(await read($, sessions), now, false, myId || undefined)
581 if (!v.isStill && all.some(s => isBusy(s, now))) await update($, frame, n => (n + 1) % 1_000_000)
582 })()
583 })
584 $.clock.every(TICK_MS, () => {
585 void (async () => {
586 const now = await $.clock.now()
587 isPaneOpen = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
588 const isCardLive = liveCard !== null && now - liveCardAt < LIVE_CARD_MS
589 const v = await read($, view)
590 const mine = await read($, me)
591 if (mine?.state === 'working' && now - lastUsage >= USAGE_MS) await refreshUsage($)
592 if (now - lastWrite >= HEARTBEAT_MS) await flush($)
593 if (now - lastTitle >= TITLE_MS) void refreshTitle($)
594 const every = isPaneOpen || isCardLive ? SCAN_LIVE_MS : v.showBand ? SCAN_BAND_MS : SCAN_IDLE_MS
595 if (now - lastScan >= every) await scan($)
596 // A colony worker whose change has held long enough moves on the next drawing: draw now.
597 const due = colonyDueAt(now)
598 if ((isPaneOpen || isCardLive) && v.showColony !== false && due !== null && due <= now) {
599 noteColonyForced(now)
600 await scan($, true)
601 }
602 })()
603 })
604 return started
605 })
606
607 on('session.end', async ($, e, next) => {
608 openDialogs.clear()
609 await patch($, s => ({ ...s, state: 'ended', now: e.reason === 'clear' ? 'Cleared' : 'Session ended', agents: s.agents.filter(a => a.state !== 'working') }))
610 await flush($)
611 const ended = await next(e)
612 if (e.reason === 'clear' || e.reason === 'resume') await followSession($)
613 return ended
614 })
615
616 // /fleet opens the dashboard in the app's side panel. Where the surface places no panel, it draws
617 // the dashboard as a live card in the transcript instead. The fresh scan follows on a timer,
618 // outside this dispatch, so a slow disk or a session still starting up never leaves it unanswered.
619 on('command.run', { command: 'fleet' }, async ($, e) => {
620 const arg = e.args.trim().toLowerCase()
621 if (arg === 'band') {
622 const v = await update($, view, x => ({ ...x, showBand: !x.showBand }))
623 if (v !== undefined) void $.store.set('view', v)
624 return { text: v?.showBand ? 'Fleet band shown above the prompt.' : 'Fleet band hidden; /fleet band brings it back.' }
625 }
626 if (arg === 'alerts') {
627 const v = await saveView($, x => ({ ...x, showAlerts: x.showAlerts === false }))
628 return { text: v?.showAlerts === false ? 'Fleet alerts off; /fleet alerts turns them back on.' : 'Fleet alerts on: a toast here when another session needs you, while this panel is open.' }
629 }
630 if (arg !== '' && !['still', 'animate', 'ended', 'colony'].includes(arg)) {
631 return { text: `Unknown option "${arg}". Try: /fleet, /fleet band, /fleet colony, /fleet ended, /fleet still, /fleet animate, /fleet alerts.` }
632 }
633 // What changed, and how to undo it, leads the reply; the panel opens as for a bare /fleet.
634 let changed = ''
635 if (arg === 'still' || arg === 'animate') {
636 void saveView($, v => ({ ...v, isStill: arg === 'still' }))
637 changed = arg === 'still' ? 'Motion stopped; /fleet animate restarts it. ' : 'Motion restarted; /fleet still stops it. '
638 }
639 if (arg === 'ended') {
640 const v = await saveView($, x => ({ ...x, showEnded: !x.showEnded }))
641 changed = v?.showEnded ? 'Showing ended sessions and those idle over 12 hours; /fleet ended hides them. ' : 'Ended sessions and those idle over 12 hours hidden; /fleet ended shows them. '
642 }
643 if (arg === 'colony') {
644 const v = await saveView($, x => ({ ...x, showColony: x.showColony === false }))
645 changed = v?.showColony === false ? 'Colony hidden; /fleet colony shows it. ' : 'Colony shown; /fleet colony hides it. '
646 }
647 $.clock.after(1, () => void scan($, true))
648 const opened = await $.ui.open({ id: PANE, title: 'Fleet' })
649 isPaneOpen = opened.isPlaced
650 if (opened.isPlaced) return { text: `${changed}Fleet is open in the side panel.` }
651 isCardPending = true
652 liveCardAt = await $.clock.now()
653 const sum = summarize(arrange(await read($, sessions), liveCardAt, false, myId || undefined), liveCardAt)
654 return { text: `${changed}Fleet: ${[plural(sum.sessions, 'session'), sum.working > 0 ? `${sum.working} working` : '', sum.waiting > 0 ? `${plural(sum.waiting, 'needs', 'need')} you` : '', sum.agents > 0 ? `${plural(sum.agents, 'agent')} running` : ''].filter(Boolean).join(', ')}.` }
655 }).catch(() => ({ text: 'Fleet: still starting up in this session. Run /fleet again in a moment.' }))
656
657 on('ui.render', { component: 'CommandOutput', props: { command: 'fleet' } }, async ($, e, next) => {
658 if (e.props.isErrored) return next(e)
659 const id = e.requestId
660 // Only a row never drawn before can be the run's own: an old row redrawn stays old.
661 if (!seenCards.has(id)) {
662 if (seenCards.size > 500) seenCards.clear()
663 seenCards.add(id)
664 if (isCardPending) {
665 isCardPending = false
666 liveCard = id
667 }
668 }
669 // Runs that opened the side panel, and old cards, keep their plain text.
670 const now = await $.clock.now()
671 if (id !== liveCard || now - liveCardAt >= LIVE_CARD_MS) return next(e)
672 return dashboard($.ui.resolve(e), e.surface, await board($), actionsFor($), Math.max(60, (e.viewport?.columns ?? 120) - 4), await frameFor($, e.surface))
673 })
674
675 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
676 const tree = dashboard($.ui.resolve(e), e.surface, await board($), actionsFor($), e.props.bodyColumns, await frameFor($, e.surface))
677 await dumpPane($, e.surface, e.props.bodyColumns, tree, `bodyRows=${e.props.scroll?.bodyRows} rows=${e.viewport?.rows}`)
678 return tree
679 })
680
681 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
682 const theirs = await next(e)
683 const v = await read($, view)
684 if (!v.showBand || e.props.hasSurvey) return theirs
685 const ui = $.ui.resolve(e)
686 const mine = band(ui, e.surface, await board($), actionsFor($), e.props.bodyColumns, await frameFor($, e.surface))
687 if (mine === null) return theirs
688 const { Box } = ui
689 return (
690 <Box flexDirection="column">
691 {mine}
692 {theirs}
693 </Box>
694 )
695 })
696
697 on('turn.start', async ($, e, next) => {
698 await followSession($)
699 const at = await $.clock.now()
700 // A background task's notification starts a turn too, but the person did not ask anything.
701 const isPrompt = e.text.trim() !== '' && !NOT_TYPED.test(e.text)
702 await patch($, s => ({
703 ...s,
704 state: 'working',
705 task: isPrompt ? firstLine(e.text, 140) : s.task,
706 now: 'Thinking…',
707 kind: 'thinking',
708 turnStartedAt: at,
709 promptAt: isPrompt ? at : (s.promptAt ?? at),
710 idleSince: null,
711 // A list that was all done belonged to the last piece of work.
712 tasks: (() => {
713 const p = progressOf(s.tasks)
714 return p && p.done === p.total ? undefined : s.tasks
715 })(),
716 turns: s.turns + 1,
717 toolsThisTurn: 0,
718 toolAt: null,
719 // A notification's turn is not the person's: it leaves the last receipt alone.
720 tally: isPrompt ? { edits: 0, errors: 0, agents: 0, costStart: s.costUsd } : null,
721 }))
722 return next(e)
723 })
724
725 on('turn.complete', async ($, e, next) => {
726 const result = await next(e)
727 const at = await $.clock.now()
728 if (e.agentId !== undefined) {
729 const agentId = e.agentId
730 // A finished subagent has no dialog open, whatever became of the call that had one.
731 openDialogs.delete(agentId)
732 await patchAgent($, agentId, a => ({
733 ...a,
734 state: e.reason === 'answer' ? 'done' : 'failed',
735 now: e.reason === 'answer' ? firstLine(e.answer, 80) || 'Done' : `Stopped: ${e.reason}`,
736 kind: undefined,
737 endedAt: at,
738 tokens: a.steps === 0 && e.usage ? addUsage(ZERO, e.usage) : a.tokens,
739 }))
740 return result
741 }
742 await patch($, s => ({
743 ...s,
744 state: 'idle',
745 now: e.reason === 'answer' ? 'Waiting for your reply' : e.reason === 'aborted' ? 'Interrupted' : `Stopped: ${e.reason}`,
746 kind: undefined,
747 last: firstLine(e.answer, 140) || s.last,
748 turnStartedAt: null,
749 toolAt: null,
750 idleSince: at,
751 // Subagents the turn left running stay; finished ones older than the turn fade.
752 // Lost subagents (marked running, long silent) are let go here: their end is never coming.
753 agents: s.agents.filter(a => (a.state === 'working' ? isAgentLive(a, at) : (a.endedAt ?? at) >= (s.turnStartedAt ?? 0))),
754 }))
755 await refreshUsage($)
756 // The turn's receipt, with its cost once the usage above has caught up.
757 await patch($, s =>
758 s.tally && s.turnStartedAt === null
759 ? {
760 ...s,
761 receipt: {
762 ms: at - (s.promptAt ?? at),
763 edits: s.tally.edits,
764 errors: s.tally.errors,
765 agents: s.tally.agents,
766 costUsd: s.costUsd !== null && s.tally.costStart !== null ? s.costUsd - s.tally.costStart : null,
767 endedAt: at,
768 },
769 tally: null,
770 }
771 : s,
772 )
773 await flush($)
774 if (at - lastTitle >= 60_000) void refreshTitle($)
775 return result
776 })
777
778 // Every model request, the main loop's and each subagent's: tokens and live context.
779 on('turn.step', async function* ($, e, next) {
780 // The cache's lifetime restarts when the request goes out, not when the reply ends.
781 const startedAt = await $.clock.now()
782 const result = yield* next(e)
783 const usage = result.usage
784 if (!usage) return result
785 const agentId = e.agentId
786 const mine = await read($, me)
787 // A loop no listed agent owns: a workflow's agent, or a fork (compaction, memory, a plugin's).
788 if (agentId !== undefined && mine !== null && !mine.agents.some(a => a.id === agentId)) {
789 const at = await $.clock.now()
790 const tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0)
791 await patch($, s => {
792 const loops = s.loops ?? []
793 const was = loops.find(l => l.id === agentId)
794 const loop = was
795 ? { ...was, steps: was.steps + 1, tokens: was.tokens + tokens, lastAt: at, model: usage.model || was.model }
796 : { id: agentId, model: usage.model || e.model || '', steps: 1, tokens, startedAt: at, lastAt: at }
797 return { ...s, loops: [...loops.filter(l => l.id !== agentId), loop].slice(-12) }
798 })
799 return result
800 }
801 if (agentId === undefined) {
802 // Each reply writes or reads the prompt cache, which restarts its lifetime.
803 await patch($, s => ({ ...s, model: usage.model || e.model || s.model, effort: typeof e.effort === 'string' ? e.effort : s.effort, tokens: addUsage(s.tokens, usage), cacheAt: startedAt }))
804 return result
805 }
806 const model = usage.model || e.model
807 const stepAt = await $.clock.now()
808 await patchAgent($, agentId, a => ({
809 ...a,
810 model: model || a.model,
811 lastAt: stepAt,
812 hitMax: a.hitMax || result.stopReason === 'max_tokens',
813 tokens: addUsage(a.tokens, usage),
814 contextTokens: (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.output_tokens ?? 0),
815 contextWindow: windowOf(model || a.model),
816 steps: a.steps + 1,
817 }))
818 return result
819 })
820
821 on('agent.spawn', async ($, e, next) => {
822 const started = await next(e)
823 if (started.deny !== undefined) return started
824 const at = await $.clock.now()
825 const id = started.agentId ?? e.tool_use_id
826 const mine = await read($, me)
827 const taken = (mine?.agents ?? []).map(x => x.name ?? '')
828 const agent: FleetAgent = {
829 id,
830 name: e.name || pickName(id, taken),
831 type: e.subagentType,
832 description: firstLine(e.description, 80),
833 model: started.model ?? e.model ?? '',
834 state: 'working',
835 now: 'Starting…',
836 kind: 'thinking',
837 startedAt: at,
838 endedAt: null,
839 tokens: ZERO,
840 contextTokens: 0,
841 contextWindow: windowOf(started.model ?? e.model ?? ''),
842 steps: 0,
843 tools: 0,
844 lastAt: at,
845 }
846 await patch($, s => ({
847 ...s,
848 agents: [...s.agents.filter(a => a.id !== agent.id), agent].slice(-30),
849 tally: s.tally ? { ...s.tally, agents: s.tally.agents + 1 } : s.tally,
850 }))
851 // The panel opens by itself once, at the session's first subagent, unless the person closed it.
852 if (mine !== null && !mine.panelOpenedOnce && !mine.panelClosed && !isPaneOpen) {
853 await patch($, s => ({ ...s, panelOpenedOnce: true }))
854 $.clock.after(1, () => void $.ui.open({ id: PANE, title: 'Fleet' }).then(opened => (isPaneOpen = opened.isPlaced)))
855 }
856 return started
857 })
858
859 // A call that needs the person's approval: the session waits on them. The dialog's own event says
860 // so for certain. A check that answers "ask" says so too, unless the permission mode settles asks
861 // without the person (auto mode's classifier, bypass, don't-ask), or it is a plugin's query that
862 // runs nothing (no tool_use_id).
863 on('classic.PermissionRequest', async ($, e, next) => {
864 const input = e as { agent_id?: unknown; tool_name?: unknown; tool_input?: unknown }
865 if (input.agent_id === undefined) await markWaiting($, String(input.tool_name ?? ''), input.tool_input)
866 else {
867 const id = String(input.agent_id)
868 const agent = (await read($, me))?.agents.find(a => a.id === id)
869 await markAgentWaiting($, { id, name: agent?.name || agent?.type || 'subagent' }, String(input.tool_name ?? ''), input.tool_input)
870 }
871 return next(e)
872 })
873 on('tool.check', async ($, e, next) => {
874 const verdict = await next(e)
875 // A question or a plan keeps its own words (tool.call sets them).
876 if (verdict.decision === 'ask' && e.agentId === undefined && e.tool_use_id !== undefined && !ASKS.has(e.tool)) {
877 const mine = await read($, me)
878 // Outside a turn (a plugin's own call while idle) the session waits on no one.
879 if (mine?.turnStartedAt != null && !SETTLES_ASKS.has(mine.mode ?? '')) await markWaiting($, e.tool, e.input)
880 }
881 return verdict
882 })
883
884 on('tool.call', async ($, e, next) => {
885 const tool = String(e.tool)
886 const what = describeTool(tool, e as unknown as Record<string, unknown>)
887 const kind = kindOfTool(tool)
888 const agentId = (e as { agentId?: string }).agentId
889 const startedAt = await $.clock.now()
890 if (agentId !== undefined) {
891 await patchAgent($, agentId, a => ({ ...a, now: what, kind, tools: a.tools + 1, toolAt: startedAt, lastAt: startedAt }))
892 } else {
893 // Outside a turn (a plugin's own tool call while idle) the session doesn't turn busy.
894 await patch($, s =>
895 s.turnStartedAt === null ? { ...s, toolsTotal: s.toolsTotal + 1 } : { ...s, state: 'working', now: what, kind, toolAt: startedAt, toolsThisTurn: s.toolsThisTurn + 1, toolsTotal: s.toolsTotal + 1 },
896 )
897 // A question or a plan for approval holds the call until the person answers: they are needed.
898 const asks = ASKS.get(tool)
899 if (asks !== undefined) {
900 await patch($, s => ({ ...s, state: 'waiting', now: asks, kind: 'waiting' }))
901 await flush($)
902 }
903 }
904 let result: Awaited<ReturnType<typeof next>>
905 try {
906 result = await next(e)
907 } finally {
908 // The call that had this subagent's dialog open is over, however it ended: the dialog was answered.
909 if (agentId !== undefined && openDialogs.get(agentId)?.tool === tool && openDialogs.delete(agentId)) {
910 await patchAgent($, agentId, a => ({ ...a, now: a.now.startsWith('Needs your OK') ? 'Thinking…' : a.now, kind: a.kind === 'waiting' ? 'thinking' : a.kind }))
911 }
912 }
913 const endedAt = await $.clock.now()
914 const input = e as unknown as Record<string, unknown>
915 const output = 'result' in result ? result.result : undefined
916 const isError = result.isError === true || result.deny !== undefined
917 if (agentId === undefined) {
918 await patch($, s => {
919 const tasks = tasksAfter(s.tasks, tool, input, output)
920 const tally = s.tally ? { ...s.tally, edits: s.tally.edits + (EDIT_TOOLS.has(tool) && !isError ? 1 : 0), errors: s.tally.errors + (isError ? 1 : 0) } : s.tally
921 if (s.turnStartedAt === null) {
922 // No turn: a wait left from a call outside one ends with the call, and nothing turns busy.
923 return s.state === 'waiting' ? { ...s, state: 'idle', now: 'Waiting for your reply', kind: undefined, toolAt: null, tasks, tally } : { ...s, toolAt: null, tasks, tally }
924 }
925 return s.state === 'working' || s.state === 'waiting'
926 ? { ...s, state: 'working', now: 'Thinking…', kind: 'thinking', toolAt: null, tasks, tally }
927 : { ...s, toolAt: null, tasks, tally }
928 })
929 } else {
930 // A worker's own step report (savvy-progress's step tool) is its progress.
931 const count = (n: unknown): number => (Number.isFinite(Number(n)) ? Math.max(0, Number(n)) : 0)
932 const step = tool === 'mcp__savvy-progress__step' ? { done: count(input.done), total: count(input.total) } : null
933 await patchAgent($, agentId, a => ({
934 ...a,
935 kind: a.state === 'working' ? 'thinking' : a.kind,
936 toolAt: null,
937 lastAt: endedAt,
938 progress:
939 step && step.total > 0
940 ? { done: Math.min(step.done, step.total), total: step.total }
941 : step && a.progress
942 ? { ...a.progress, done: Math.min(step.done, a.progress.total) }
943 : a.progress,
944 }))
945 }
946 return result
947 })
948
949 // A compaction of the main conversation: mark how full the context was when it ran.
950 on('session.compact', async ($, e, next) => {
951 const before = await read($, me)
952 const result = await next(e)
953 if (e.agentId === undefined && e.trigger !== 'precompute' && 'messages' in result && result.messages !== undefined && before !== null) {
954 const percent = before.contextPercent ?? (before.contextWindow ? (before.contextTokens / before.contextWindow) * 100 : null)
955 if (percent !== null) await patch($, s => ({ ...s, compactions: [...(s.compactions ?? []), Math.round(percent)].slice(-6) }))
956 }
957 // A compaction starts a new prefix that nothing has cached yet: no cache time until the next reply.
958 if (e.agentId === undefined && e.trigger !== 'precompute' && 'messages' in result && result.messages !== undefined) {
959 await patch($, s => ({ ...s, cacheAt: null }))
960 }
961 return result
962 })
963
964 // The permission mode rides on the classic hook inputs: each prompt and tool call carries it.
965 on('classic.UserPromptSubmit', async ($, e, next) => {
966 await noteMode($, (e as { permission_mode?: unknown }).permission_mode)
967 return next(e)
968 })
969 on('classic.PostToolUse', async ($, e, next) => {
970 if ((e as { agent_id?: unknown }).agent_id === undefined) await noteMode($, (e as { permission_mode?: unknown }).permission_mode)
971 return next(e)
972 })
973
974 // A panel the person closed stays closed: it no longer opens by itself this session, reloads included.
975 on('ui.close', { id: PANE }, async ($, e, next) => {
976 if (e.origin.kind === 'person') await patch($, s => ({ ...s, panelClosed: true }))
977 isPaneOpen = false
978 return next(e)
979 })
980}
981hooks/describe.ts 595 lines1import type { FleetAgent, FleetLimit, FleetLoop, FleetProgress, FleetReceipt, FleetSession, FleetTokens } from '../types'
2
3const cut = (text: string, max: number): string => {
4 const one = text.replace(/\s+/g, ' ').trim()
5 return one.length > max ? `${one.slice(0, max - 1)}…` : one
6}
7
8const base = (path: unknown): string => String(path ?? '').split(/[\\/]/).filter(Boolean).at(-1) ?? ''
9
10const host = (url: unknown): string => {
11 try {
12 return new URL(String(url)).host
13 } catch {
14 return cut(String(url ?? ''), 30)
15 }
16}
17
18// What a tool call is doing, in a few words: "Editing register.tsx", "Running npm test".
19export function describeTool(tool: string, input: Record<string, unknown>): string {
20 switch (tool) {
21 case 'Read':
22 return `Reading ${base(input.file_path)}`
23 case 'Edit':
24 case 'MultiEdit':
25 return `Editing ${base(input.file_path)}`
26 case 'Write':
27 return `Writing ${base(input.file_path)}`
28 case 'NotebookEdit':
29 return `Editing ${base(input.notebook_path)}`
30 case 'Bash':
31 case 'PowerShell':
32 // A command's own description already reads as a step ("Run the tests").
33 return input.description ? cut(String(input.description), 56) : `Running ${cut(String(input.command ?? ''), 50)}`
34 case 'Grep':
35 return `Searching for ${cut(String(input.pattern ?? ''), 30)}`
36 case 'Glob':
37 return `Finding ${cut(String(input.pattern ?? ''), 30)}`
38 case 'WebFetch':
39 return `Reading ${host(input.url)}`
40 case 'WebSearch':
41 return `Searching the web: ${cut(String(input.query ?? ''), 36)}`
42 case 'Agent':
43 case 'Task':
44 return `Delegating: ${cut(String(input.description ?? ''), 40)}`
45 case 'Skill':
46 return `Using the ${String(input.skill ?? '')} skill`
47 case 'TodoWrite':
48 return 'Updating the plan'
49 case 'AskUserQuestion':
50 return 'Asking you a question'
51 case 'ToolSearch':
52 return 'Loading tools'
53 }
54 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
55 if (mcp) {
56 const raw = mcp[1] ?? ''
57 // The app's own browsers and desktop control read as what they are.
58 const known: Record<string, string> = { Claude_Browser: 'the browser', 'claude-in-chrome': 'Chrome', 'computer-use': 'the desktop', playwright: 'Playwright' }
59 if (known[raw]) return `Using ${known[raw]}`
60 const server = /^[0-9a-f-]{20,}$/.test(raw) ? 'connector' : raw.replace(/^plugin_[^_]+_/, '').replace(/_/g, ' ')
61 return `Using ${server} ${(mcp[2] ?? '').replace(/_/g, ' ')}`
62 }
63 return `Using ${tool}`
64}
65
66// "claude-opus-5-5" → "Opus 5.5"; anything unfamiliar stays as it came.
67export function modelName(model: string): string {
68 const match = /(opus|sonnet|haiku|fable)[-_ ]?(\d+)(?:[-.](\d+))?/i.exec(model)
69 if (!match) return model || '?'
70 const family = (match[1] ?? '').charAt(0).toUpperCase() + (match[1] ?? '').slice(1).toLowerCase()
71 return `${family} ${match[2]}${match[3] ? `.${match[3]}` : ''}`
72}
73
74export const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
75
76export const totalTokens = (t: FleetTokens): number => t.input + t.output + t.cacheRead + t.cacheWrite
77
78export const addUsage = (
79 t: FleetTokens,
80 u: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number },
81): FleetTokens => ({
82 input: t.input + (u.input_tokens ?? 0),
83 output: t.output + (u.output_tokens ?? 0),
84 cacheRead: t.cacheRead + (u.cache_read_input_tokens ?? 0),
85 cacheWrite: t.cacheWrite + (u.cache_creation_input_tokens ?? 0),
86})
87
88export const ZERO: FleetTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
89
90// Rounded first, then given its unit, so 999,600 reads "1.0M", never "1000k".
91export function formatTokens(n: number): string {
92 if (n >= 999_500) return n >= 9_950_000 ? `${Math.round(n / 1_000_000)}M` : `${(n / 1_000_000).toFixed(1)}M`
93 if (n >= 1_000) return `${Math.round(n / 1_000)}k`
94 return String(n)
95}
96
97// "1 agent", "2 agents".
98export const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`
99
100// What a session's id from the desktop app may hold before it goes into a link or a command.
101export const isHostId = (id: string): boolean => /^[\w-]{1,80}$/.test(id)
102
103// The plan's own windows: a gateway's spend limit is real money, not a plan.
104export const isPlanLimit = (l: { kind: string }): boolean => l.kind === 'five_hour' || l.kind === 'seven_day'
105
106// Time to a reset, compact: "6d 21h", "1h12m", "12m".
107export function resetIn(ms: number): string {
108 const m = Math.max(1, Math.floor(ms / 60_000))
109 if (m >= 1_440) return `${Math.floor(m / 1_440)}d ${Math.floor((m % 1_440) / 60)}h`
110 if (m >= 60) return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
111 return `${m}m`
112}
113
114// One reading of a limit, kept in memory to forecast from.
115export type LimitSample = { at: number; pct: number; resetsAt: string | null }
116
117// Whether two readings are of the same window: the same instant, however each session spells it.
118export function sameWindow(a: string | null, b: string | null): boolean {
119 if (a === null || b === null) return a === b
120 const x = Date.parse(a)
121 const y = Date.parse(b)
122 return Number.isFinite(x) && Number.isFinite(y) ? x === y : a === b
123}
124
125// Time to a limit's full, compact; under a minute reads "<1m".
126export const fullIn = (ms: number): string => (ms < 60_000 ? '<1m' : resetIn(ms))
127
128// Whether the status line shows the forecast: once under an hour, and kept until it passes 70 minutes.
129export const isFullShown = (wasShown: boolean, ms: number | undefined): boolean => ms !== undefined && (ms < 3_600_000 || (wasShown && ms < 70 * 60_000))
130
131// Ms until a limit reaches 100% at the current pace (least-squares slope over the last 30 minutes of
132// this window), or null: too little to go on, not rising lately, full already, or not full before the reset.
133export function forecastFull(samples: readonly LimitSample[], resetsAt: string | null, now: number): number | null {
134 const resets = resetsAt !== null ? Date.parse(resetsAt) : NaN
135 if (!Number.isFinite(resets)) return null
136 const pts = samples.filter(p => sameWindow(p.resetsAt, resetsAt) && p.at <= now && now - p.at <= 30 * 60_000).sort((a, b) => a.at - b.at)
137 if (pts.length < 3) return null
138 const last = pts[pts.length - 1]
139 if (last.at - pts[0].at < 10 * 60_000 || last.pct >= 100) return null
140 // Use that stopped has to stop the warning: the latest reading must be above the one 10 minutes before it.
141 const before = pts.filter(p => p.at <= last.at - 10 * 60_000).pop()
142 if (!before || last.pct <= before.pct) return null
143 const n = pts.length
144 const mx = pts.reduce((t, p) => t + p.at, 0) / n
145 const my = pts.reduce((t, p) => t + p.pct, 0) / n
146 const sxx = pts.reduce((t, p) => t + (p.at - mx) ** 2, 0)
147 const slope = pts.reduce((t, p) => t + (p.at - mx) * (p.pct - my), 0) / sxx
148 if (!(slope > 0)) return null
149 const fullAt = last.at + (100 - last.pct) / slope
150 if (fullAt >= resets) return null
151 return Math.max(0, fullAt - now)
152}
153
154export function formatDuration(ms: number): string {
155 const s = Math.max(0, Math.floor(ms / 1000))
156 if (s < 60) return `${s}s`
157 const m = Math.floor(s / 60)
158 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
159 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
160}
161
162// A bar of `width` cells for a fraction from 0 to 1.
163export function meter(fraction: number, width: number): string {
164 const filled = Math.round(Math.max(0, Math.min(1, fraction)) * width)
165 return '▰'.repeat(filled) + '▱'.repeat(width - filled)
166}
167
168// The little worker for each kind of work. Every frame of a set has the same width,
169// so nothing beside it jitters. ASCII and narrow glyphs only, for every terminal.
170export const SPRITES: Readonly<Record<string, readonly string[]>> = {
171 thinking: ['(•ω•) ', '(•ω•) . ', '(•ω•) .o ', '(•ω•) .oO'],
172 reading: ['(•_•)⊃▤ ', '( •_•)⊃▤ ', '(•_• )⊃▤ ', '(•_•)⊃▤ '],
173 editing: ['(•ᴗ•)✎ ', '(•ᴗ•) ✎ ', '(•ᴗ•) ✎ ', '(•ᴗ•) ✎ '],
174 running: ['ᕕ(•ᴗ•)ᕗ ', ' ᕕ(•ᴗ•)ᕗ ', ' ᕕ(•ᴗ•)ᕗ', ' ᕙ(•ᴗ•)ᕗ '],
175 searching: ['(•_•)⌕ ', '(•_•) ⌕ ', '(•_•) ⌕ ', '(•_•) ⌕ '],
176 browsing: ['(•ᴗ•)~≈~ ', '(•ᴗ•)≈~≈ ', '(•ᴗ•)~≈~ ', '(•ᴗ•)≈~≈ '],
177 delegating: ['(•ᴗ•)/° ', '(•ᴗ•)/ ° ', '(•ᴗ•)/ °', '(•ᴗ•)/ ° '],
178 waiting: ['(°_°) ? ', '(°_°) ? ', '(°_°) ? ', '(°_°) ! '],
179 idle: ['(-_-) z ', '(-_-) zZ ', '(-_-) Zz', '(-_-) z'],
180 done: ['(^ᴗ^)✓ ', '(^ᴗ^)✓ ', '(^ᴗ^)✓ ', '(^ᴗ^)✓ '],
181 failed: ['(×_×) ', '(×_×) ', '(×_×) ', '(×_×) '],
182 ended: ['(^_^)/~ ', '(^_^)/~ ', '(^_^)/~ ', '(^_^)/~ '],
183}
184
185// Which worker a tool calls for.
186export function kindOfTool(tool: string): string {
187 if (['Read', 'NotebookRead', 'ToolSearch'].includes(tool)) return 'reading'
188 if (['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'TodoWrite'].includes(tool)) return 'editing'
189 if (['Bash', 'PowerShell'].includes(tool)) return 'running'
190 if (['Grep', 'Glob'].includes(tool)) return 'searching'
191 if (['Agent', 'Task'].includes(tool)) return 'delegating'
192 return 'browsing'
193}
194
195// Which worker a line of activity calls for: the recorded kind, else read from the words.
196export function spriteKind(state: string, now: string, kind?: string): string {
197 if (state !== 'working') return state
198 if (kind) return kind
199 const verb = now.split(' ')[0] ?? ''
200 if (/^Reading|^Loading/.test(verb)) return 'reading'
201 if (/^Editing|^Writing|^Updating/.test(verb)) return 'editing'
202 if (/^Running/.test(verb)) return 'running'
203 if (/^Searching|^Finding/.test(verb)) return /web/.test(now) ? 'browsing' : 'searching'
204 if (/^Delegating/.test(verb)) return 'delegating'
205 if (/^Using/.test(verb)) return 'browsing'
206 return 'thinking'
207}
208
209export const sprite = (kind: string, frame: number, isStill: boolean): string => {
210 const frames = SPRITES[kind] ?? SPRITES.thinking ?? ['']
211 return frames[isStill ? 0 : frame % frames.length] ?? ''
212}
213
214const STALE_MS = 90_000
215
216// A heartbeat that stopped without saying it ended: the session closed or crashed.
217export const isGone = (s: FleetSession, now: number): boolean => s.state !== 'ended' && now - s.updatedAt > STALE_MS
218
219// When the person last prompted; older records without it fall back to the nearest time they kept.
220export const promptedAt = (s: FleetSession): number => s.promptAt ?? s.turnStartedAt ?? s.idleSince ?? s.startedAt
221
222// What a run with no screen and nothing to show leaves behind once it has ended or gone quiet (-p, the
223// SDK): no surface, no title, no cost and at most a turn. A desktop session restored at app start has
224// no surface either, but has a title or a cost, so it stays.
225export const isClutter = (s: FleetSession): boolean => s.state === 'ended' && s.surface === '' && !s.title && (s.costUsd ?? 0) === 0 && s.turns <= 1
226
227// How long a waiting session has waited on the person (0 with no start known).
228export const waitingFor = (s: FleetSession, now: number): number => (s.state === 'waiting' && s.waitingSince ? Math.max(0, now - s.waitingSince) : 0)
229
230// The wait as "6m", once it is a minute or more; else empty.
231export const waitText = (s: FleetSession, now: number): string => (waitingFor(s, now) >= 60_000 ? ago(waitingFor(s, now)) : '')
232
233// How long a session may rest untouched before it is put away with the ended ones. The desktop app
234// keeps every unarchived session loaded, so its heartbeat stays fresh for days.
235export const DORMANT_MS = 12 * 3_600_000
236
237// Idle past DORMANT_MS with nothing at work: no prompt, no subagent, no one waiting.
238export const isDormant = (s: FleetSession, now: number): boolean => s.state === 'idle' && !isBusy(s, now) && now - (s.idleSince ?? promptedAt(s)) >= DORMANT_MS
239
240// Open sessions only (ended, vanished and dormant ones show with showEnded; `keepId`, the session
241// looking, always shows), the ones that need the person first, longest-waiting first, then the most
242// recently prompted. Working is not ranked above idle: rows that reshuffle as sessions start and stop
243// are hard to track.
244export function arrange(sessions: readonly FleetSession[], now: number, showEnded: boolean, keepId?: string): FleetSession[] {
245 return sessions
246 .map(s => (isGone(s, now) ? { ...s, state: 'ended' as const, now: 'No heartbeat: closed or stopped' } : s))
247 .filter(s => (showEnded || (s.state !== 'ended' && (s.id === keepId || !isDormant(s, now)))) && !isClutter(s))
248 .sort((a, b) => Number(b.state === 'waiting') - Number(a.state === 'waiting') || (a.state === 'waiting' && b.state === 'waiting' ? waitingFor(b, now) - waitingFor(a, now) : 0) || promptedAt(b) - promptedAt(a) || b.updatedAt - a.updatedAt)
249}
250
251const HOUR_MS = 3_600_000
252const FIVE_MIN_MS = 5 * 60_000
253
254// The prompt cache's lifetime. Subscription sessions write it for an hour; API-key sessions,
255// and subscriptions past a plan limit (usage overage), get five minutes. Only the plan's own windows
256// count: a gateway's spend limit says nothing of the cache. A session with no plan reading of its own
257// is judged on the fleet's through withFleetLimits.
258export const cacheTtlOf = (s: FleetSession, now = Infinity): number => {
259 const plan = s.limits.filter(isPlanLimit)
260 return plan.length === 0 || plan.some(l => l.percentUsed >= 100 && !isOver(l, now)) ? FIVE_MIN_MS : HOUR_MS
261}
262
263// Whether a plan limit's window has already reset (an old reading kept in an idle session's heartbeat).
264const isOver = (l: FleetLimit, now: number): boolean => l.resetsAt !== null && Number.isFinite(Date.parse(l.resetsAt)) && Date.parse(l.resetsAt) <= now
265
266// The plan limits in force, one per kind, from every session's readings: a window that has reset is
267// over; of the rest the latest window wins, and within a window the highest reading (usage only
268// rises), so the header holds steady whichever session wrote last.
269export function currentLimits(sessions: readonly FleetSession[], now: number): FleetLimit[] {
270 const best = new Map<string, FleetLimit>()
271 const resets = (l: FleetLimit) => (l.resetsAt !== null && Number.isFinite(Date.parse(l.resetsAt)) ? Date.parse(l.resetsAt) : -Infinity)
272 for (const s of sessions) {
273 for (const l of s.limits) {
274 if (isOver(l, now)) continue
275 const was = best.get(l.kind)
276 if (!was || resets(l) > resets(was) || (resets(l) === resets(was) && l.percentUsed > was.percentUsed)) best.set(l.kind, l)
277 }
278 }
279 const order = (kind: string) => (kind === 'five_hour' ? 0 : kind === 'seven_day' ? 1 : 2)
280 return [...best.values()].sort((a, b) => order(a.kind) - order(b.kind))
281}
282
283// How long the main agent's prompt cache has left: null before the first reply, 0 once cold.
284export function cacheLeft(s: FleetSession, now: number): { ms: number; ttl: number } | null {
285 if (!s.cacheAt) return null
286 const ttl = cacheTtlOf(s, now)
287 return { ms: Math.max(0, s.cacheAt + ttl - now), ttl }
288}
289
290// "52m", "<1m", "cold".
291export const cacheText = (left: { ms: number } | null): string =>
292 left === null ? '' : left.ms <= 0 ? 'cold' : left.ms < 60_000 ? '<1m' : `${Math.ceil(left.ms / 60_000)}m`
293
294// A reading with the whole fleet's plan limits: an idle session never refreshes its own, and the
295// 5-hour window moves with every session's use. A session known to be on a plan (hasPlan) takes the
296// fleet's; with no reading anywhere it stays as it is. An API-key session never has a reading of its own,
297// and the fleet's must not give it an hour's cache.
298export function withFleetLimits(s: FleetSession, all: readonly FleetSession[], now: number): FleetSession {
299 if (s.hasPlan !== true) return s
300 const limits = currentLimits([s, ...all.filter(x => x.id !== s.id)], now)
301 return limits.length > 0 ? { ...s, limits } : s
302}
303
304export type FleetSummary = { sessions: number; working: number; waiting: number; agents: number; costUsd: number }
305
306// A subagent marked running but silent this long is lost: its end never arrived (a reload or a crash
307// mid-run). One with a tool running is given longer, since a long build is quiet but busy.
308export const LOST_AGENT_MS = 20 * 60_000
309const LONG_TOOL_MS = 60 * 60_000
310
311// Whether a subagent is still at work, by its own activity: one running in the background works on
312// while its session rests between turns.
313export function isAgentLive(a: FleetAgent, now: number): boolean {
314 if (a.state !== 'working') return false
315 if (a.toolAt) return now - a.toolAt < LONG_TOOL_MS
316 return now - Math.max(a.lastAt ?? 0, a.startedAt) < LOST_AGENT_MS
317}
318
319// A session counts as busy while it works, waits on the person, or has a subagent still at work.
320export const isBusy = (s: FleetSession, now: number): boolean => s.state === 'working' || s.state === 'waiting' || s.agents.some(a => isAgentLive(a, now))
321
322// `now` defaults to the newest heartbeat, for callers without a clock.
323export function summarize(sessions: readonly FleetSession[], now = Math.max(0, ...sessions.map(s => s.updatedAt))): FleetSummary {
324 const live = sessions.filter(s => s.state !== 'ended')
325 return {
326 sessions: live.length,
327 // Working includes a resting session whose subagents are still at it.
328 working: live.filter(s => s.state !== 'waiting' && isBusy(s, now)).length,
329 waiting: live.filter(s => s.state === 'waiting').length,
330 agents: live.reduce((n, s) => n + s.agents.filter(a => isAgentLive(a, now)).length, 0),
331 costUsd: live.reduce((n, s) => n + (s.costUsd ?? 0), 0),
332 }
333}
334
335// A desktop session record's CLI session id and whether the person archived it; null when it isn't
336// whole JSON (a file caught mid-write) or has no id.
337export function archiveOf(text: string): { cli: string; isArchived: boolean } | null {
338 try {
339 const value = JSON.parse(text) as { cliSessionId?: unknown; isArchived?: unknown }
340 return typeof value.cliSessionId === 'string' ? { cli: value.cliSessionId, isArchived: value.isArchived === true } : null
341 } catch {
342 return null
343 }
344}
345
346export function parseSession(text: string): FleetSession | null {
347 try {
348 const value = JSON.parse(text) as Partial<FleetSession>
349 return value.v === 1 && typeof value.id === 'string' ? (value as FleetSession) : null
350 } catch {
351 return null
352 }
353}
354
355// A turn's text that the engine wrapped (a background task's notification, a reminder, a command's
356// output), not one the person typed. A prompt that merely starts with markup ("<button> is off") is
357// the person's.
358export const NOT_TYPED = /^\s*<(task-notification|system-reminder|local-command-[\w-]+|command-[\w-]+|bash-[\w-]+|user-prompt-submit-hook|teammate-message|agent-message)\b/i
359
360// Text as a row shows it: a link as its words (a cut one too), no bold marks or backticks, one line.
361// An underscore is left alone: __init__ is a name, not emphasis.
362export const plain = (text: string): string =>
363 text
364 .replace(/\[([^\]]*)\]\([^)]*\)?/g, '$1')
365 .replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*/g, '$1')
366 .replace(/`/g, '')
367 .replace(/\s+/g, ' ')
368 .trim()
369
370// What the session was asked, as a row shows it: nothing for an engine-wrapped turn.
371export const taskText = (s: FleetSession): string => (NOT_TYPED.test(s.task) ? '' : plain(s.task))
372
373// "4m", "1h12m": a time with its seconds left off once past a minute.
374export const ago = (ms: number): string => (ms < 60_000 ? formatDuration(ms) : formatDuration(Math.floor(ms / 60_000) * 60_000).replace(/00s$/, ''))
375
376export const firstLine = (text: string, max: number): string => cut(text.split('\n').find(line => line.trim() !== '') ?? '', max)
377
378const isScratch = (project: string): boolean => /^scratch-\d{4}-\d{2}-\d{2}-/.test(project)
379
380// What to call a session: its title, else its folder (a desktop scratch folder says nothing),
381// else what it was first asked.
382export const nameOf = (s: FleetSession): string =>
383 s.title || (s.project && !isScratch(s.project) ? s.project : '') || (s.task ? cut(s.task, 48) : '') || 'New session'
384
385export const folderOf = (s: FleetSession): string => (s.project && !isScratch(s.project) && s.project !== nameOf(s) ? s.project : '')
386
387// Done of planned in a task list; null with no list. Deleted tasks do not count.
388export function progressOf(tasks: Readonly<Record<string, string>> | undefined): FleetProgress | null {
389 const statuses = Object.values(tasks ?? {}).filter(status => status !== 'deleted')
390 return statuses.length === 0 ? null : { done: statuses.filter(status => status === 'completed').length, total: statuses.length }
391}
392
393export const percentOf = (p: FleetProgress | null | undefined): number | null => (p && p.total > 0 ? Math.round((p.done / p.total) * 100) : null)
394
395// "3/5 · 60%", or nothing without a plan.
396export const progressText = (p: FleetProgress | null | undefined): string => {
397 const percent = percentOf(p)
398 return p && percent !== null ? `${p.done}/${p.total} · ${percent}%` : ''
399}
400
401// A task list after one tool call, or the list unchanged when the call was not about tasks.
402export function tasksAfter(tasks: Readonly<Record<string, string>> | undefined, tool: string, input: Record<string, unknown>, result: unknown): Record<string, string> | undefined {
403 if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
404 return Object.fromEntries((input.todos as { status?: string }[]).map((todo, i) => [`todo-${i}`, String(todo.status ?? 'pending')]))
405 }
406 if (tool === 'TaskCreate') {
407 const id = (result as { task?: { id?: unknown } } | undefined)?.task?.id
408 return id === undefined ? tasks : { ...tasks, [String(id)]: 'pending' }
409 }
410 if (tool === 'TaskUpdate' && typeof input.taskId === 'string' && typeof input.status === 'string') {
411 return { ...tasks, [input.taskId]: input.status }
412 }
413 return tasks
414}
415
416// --- Attention: what has run or gone quiet long enough to look at. ---
417
418export const SLOW_TOOL_MS = 30_000
419export const QUIET_AGENT_MS = 2 * 60_000
420
421// How long the current tool call has run, once that is long enough to say so; else 0.
422export const slowFor = (toolAt: number | null | undefined, now: number): number => (toolAt && now - toolAt >= SLOW_TOOL_MS ? now - toolAt : 0)
423
424export type Flag = { text: string; level: 'warn' | 'err' }
425
426// A subagent's one flag, the most serious first: cut off at the token limit, a slow tool, gone quiet.
427export function agentFlag(a: FleetAgent, now: number): Flag | null {
428 if (a.hitMax) return { text: 'hit max tokens', level: 'err' }
429 if (a.state !== 'working') return null
430 const slow = slowFor(a.toolAt, now)
431 if (slow > 0) return { text: `slow ${formatDuration(slow)}`, level: 'warn' }
432 if (!a.toolAt && a.lastAt && now - a.lastAt >= QUIET_AGENT_MS) return { text: `quiet ${formatDuration(now - a.lastAt)}`, level: 'warn' }
433 return null
434}
435
436// Moves only when a flag would appear or its time would change by a step, so a drawing redraws that often.
437export const attentionKey = (s: FleetSession, now: number): string =>
438 [Math.floor(slowFor(s.toolAt, now) / SLOW_TOOL_MS), ...s.agents.map(a => agentFlag(a, now)?.text.replace(/\d+s$/, '') ?? '')].join(',')
439
440export const EDIT_TOOLS: ReadonlySet<string> = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
441
442// "4m12s · 12 edits · 2 agents · 1 error · $0.80"; counts of zero are left out.
443export function receiptText(r: FleetReceipt | null | undefined): string {
444 if (!r) return ''
445 const n = (count: number, word: string) => (count > 0 ? `${count} ${word}${count === 1 ? '' : 's'}` : '')
446 return [formatDuration(r.ms), n(r.edits, 'edit'), n(r.agents, 'agent'), n(r.errors, 'error'), r.costUsd !== null && r.costUsd >= 0.01 ? `$${r.costUsd.toFixed(2)}` : ''].filter(Boolean).join(' · ')
447}
448
449// The permission mode as a few words; the default says nothing.
450export function modeName(mode: string | undefined): string {
451 const names: Record<string, string> = { acceptEdits: 'accept edits', plan: 'plan mode', auto: 'auto mode', bypassPermissions: 'bypass permissions', dontAsk: "don't ask" }
452 return mode ? (names[mode] ?? '') : ''
453}
454
455const LOOP_KEEP_MS = 5 * 60_000
456
457// Loops no agent list names that ran lately: a workflow's agents, and forks.
458export const recentLoops = (s: FleetSession, now: number): FleetLoop[] => (s.loops ?? []).filter(l => now - l.lastAt < LOOP_KEEP_MS)
459
460// --- Names: every worker gets one, steady for its id and unique among its session's agents. ---
461
462const NAMES = [
463 'Ada', 'Pip', 'Juno', 'Milo', 'Nova', 'Otto', 'Rue', 'Sage', 'Tess', 'Wren', 'Bix', 'Cleo', 'Dot', 'Fig', 'Gus', 'Hazel',
464 'Iggy', 'Kit', 'Lumi', 'Moss', 'Nell', 'Olive', 'Pax', 'Quill', 'Rex', 'Sol', 'Tuck', 'Uma', 'Vik', 'Zed', 'Bean', 'Coco',
465 'Echo', 'Fern', 'Gizmo', 'Hopper', 'Indy', 'Jinx', 'Kiwi', 'Lark', 'Mabel', 'Nico', 'Orla', 'Pepper', 'Remy', 'Scout',
466]
467
468const hashOf = (text: string): number => [...text].reduce((n, ch) => (n * 31 + ch.charCodeAt(0)) >>> 0, 7)
469
470export function pickName(id: string, taken: readonly string[]): string {
471 const start = hashOf(id) % NAMES.length
472 for (let i = 0; i < NAMES.length; i += 1) {
473 const name = NAMES[(start + i) % NAMES.length] ?? 'Ada'
474 if (!taken.includes(name)) return name
475 }
476 return `${NAMES[start] ?? 'Ada'} ${taken.length}`
477}
478
479// --- Progress: exact where the work reports it, else a rough guess from how much it has done. ---
480
481// `counts` are the steps done of planned, where a worker reports them.
482export type Progress = { fraction: number; isEstimate: boolean; counts?: { done: number; total: number } }
483
484// About how many tool calls each kind of worker makes on a typical task; the guess nears its cap past it.
485const PACE: Readonly<Record<string, number>> = { 'haiku-quick': 6, Explore: 10, Plan: 14, 'haiku-worker': 14, 'sonnet-worker': 20, 'opus-worker': 22, 'opus-deep': 26 }
486const MAIN_PACE = 24
487// A guess never claims to be done: only finishing does.
488const GUESS_CAP = 0.92
489
490const guess = (work: number, pace: number): number => Math.min(GUESS_CAP, 1 - Math.exp(-work / pace))
491
492export function agentProgress(a: FleetAgent): Progress | null {
493 if (a.state === 'done') return { fraction: 1, isEstimate: false }
494 if (a.state === 'failed') return null
495 if (a.progress && a.progress.total > 0) return { fraction: a.progress.done / a.progress.total, isEstimate: false, counts: a.progress }
496 return { fraction: guess(a.tools + a.steps * 0.5, PACE[a.type] ?? 18), isEstimate: true }
497}
498
499// A working session: its task list when it keeps one, else a guess from this turn's tool calls.
500export function sessionProgress(s: FleetSession): Progress | null {
501 const plan = progressOf(s.tasks)
502 if (plan) return { fraction: plan.done / plan.total, isEstimate: false }
503 if (s.state !== 'working' && s.state !== 'waiting') return null
504 return { fraction: guess(s.toolsThisTurn, MAIN_PACE), isEstimate: true }
505}
506
507// "60%", or "~40%" for a guess.
508// An exact zero reads as steps ("0/5"): "0%" over an empty bar says nothing.
509export const progressLabel = (p: Progress | null): string =>
510 p ? (!p.isEstimate && p.counts && p.counts.done === 0 ? `0/${p.counts.total}` : `${p.isEstimate ? '~' : ''}${Math.round(p.fraction * 100)}%`) : ''
511
512// --- Captains: each session's main agent gets a name of its own, unique across the fleet. ---
513
514// Captains' names, kept apart from the subagents' so a main agent is never mistaken for a helper.
515const CAPTAINS = [
516 'Aria', 'Bram', 'Cass', 'Dax', 'Elio', 'Faye', 'Gale', 'Hugo', 'Iris', 'Jett', 'Kai', 'Luna',
517 'Mira', 'Nash', 'Opal', 'Pike', 'Quinn', 'Ravi', 'Skye', 'Theo', 'Vera', 'Wynn', 'Yara', 'Zane',
518]
519
520export type Crew = { name: string }
521
522// Oldest session first, so a session keeps its captain's name as others come and go.
523export function crewsOf(list: readonly FleetSession[]): Map<string, Crew> {
524 const crews = new Map<string, Crew>()
525 const names: string[] = []
526 for (const s of [...list].sort((a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id))) {
527 const start = hashOf(s.id)
528 const name = Array.from({ length: CAPTAINS.length }, (_, i) => CAPTAINS[(start + i) % CAPTAINS.length] ?? 'Aria').find(n => !names.includes(n)) ?? `Aria ${names.length}`
529 names.push(name)
530 crews.set(s.id, { name })
531 }
532 return crews
533}
534
535// --- Tasks: the general kind of work a worker has taken on, and so the building it works in. ---
536
537export type Task = 'develop' | 'research' | 'review' | 'test' | 'plan' | 'debug'
538
539export const TASK_NAMES: Readonly<Record<Task, string>> = { develop: 'Develop', research: 'Research', review: 'Review', test: 'Test', plan: 'Plan', debug: 'Debug' }
540
541// Agent types whose work is one task whatever they are asked.
542const ROLE_TASK: Readonly<Record<string, Task>> = { Explore: 'research', Plan: 'plan', 'claude-code-guide': 'research' }
543
544// Words that say what a job is, the more particular first; anything else is development.
545const TASK_WORDS: readonly (readonly [Task, RegExp])[] = [
546 ['review', /\b(review|critique|audit|inspect|assess|evaluat|proofread|sign.?off)\w*/i],
547 ['debug', /\b(debug|bug|crash|broken|fail|regression|diagnos|stack ?trace|why (is|does|did|isn.t|doesn.t))\w*/i],
548 ['plan', /\b(plan|design|architect|spec|outline|roadmap|strateg|scope|break (it )?down|prioriti)\w*/i],
549 ['research', /\b(research|investigat|explor|find|look ?up|search|survey|compare|summari[sz]|learn|what is|how (do|does|to))\w*/i],
550 ['test', /\b(test|lint|typecheck|benchmark|smoke)\w*/i],
551]
552
553// A worker's job from what it was asked (its description, or the person's prompt) and its role.
554export function jobOf(ask: string, role?: string): Task {
555 const fixed = role ? ROLE_TASK[role] : undefined
556 if (fixed) return fixed
557 return TASK_WORDS.find(([, words]) => words.test(ask))?.[0] ?? 'develop'
558}
559
560// Commands that test or build something.
561const TESTISH = /\b(test|tests|build|lint|typecheck|tsc|compile|deploy|check|verify|bench)\w*/i
562
563// What a worker's current step says about its task, or null when it says nothing (a pause between
564// tool calls, reading, an ordinary command). A subagent's steps mostly say its job; a main agent
565// does everything, so editing and web research speak for themselves. The colony moves a worker only
566// on a vote that holds, so one step never sends it off and back.
567export function taskVote(job: Task, kind: string, now: string, isMain: boolean): Task | null {
568 if (kind === 'waiting' || kind === 'delegating') return 'plan'
569 if (kind === 'running' && TESTISH.test(now)) return 'test'
570 if (!isMain) return kind === 'thinking' ? null : job
571 if (kind === 'editing') return job === 'debug' ? 'debug' : 'develop'
572 if (kind === 'browsing') return 'research'
573 return null
574}
575
576// How a session stands, in words. Narrow, the state column is small and the plan is left to the row's bar.
577export const stateLine = (s: FleetSession, now: number, isNarrow = false): string => {
578 // Resting between turns while its subagents work on: that is work, and the line says so.
579 const live = s.agents.filter(a => isAgentLive(a, now)).length
580 if (s.state === 'idle' && live > 0) return isNarrow ? plural(live, 'agent') : `${plural(live, 'agent')} working`
581 const plan = isNarrow ? '' : progressText(progressOf(s.tasks)) || progressLabel(sessionProgress(s))
582 if (s.state === 'working') return `${s.turnStartedAt !== null ? `working ${ago(now - s.turnStartedAt)}` : 'working'}${plan ? ` · ${plan}` : ''}`
583 if (s.state === 'waiting') return ['needs you', waitText(s, now)].filter(Boolean).join(' ')
584 if (s.state === 'ended') return 'ended'
585 return s.idleSince ? `idle ${ago(now - s.idleSince)}` : 'idle'
586}
587
588// A limit's name: "5h" or "5-hour" (long), "7d" or "7-day", else its kind with spaces.
589export const limitName = (kind: string, isLong = false): string =>
590 kind === 'five_hour' ? (isLong ? '5-hour' : '5h') : kind === 'seven_day' ? (isLong ? '7-day' : '7d') : kind.replace(/_/g, ' ')
591
592// How loud a limit reads: red under an hour to full or at 85%, amber on a forecast or 60%.
593export const limitTone = (l: FleetLimit): 'error' | 'warning' | 'inactive' =>
594 (l.fullInMs !== undefined && l.fullInMs < 3_600_000) || l.percentUsed >= 85 ? 'error' : l.fullInMs !== undefined || l.percentUsed >= 60 ? 'warning' : 'inactive'
595hooks/colony-scene.ts 322 lines1import type { FleetAgent, FleetSession } from '../types'
2import type { Crew, Progress, Task } from './describe'
3import { LOST_AGENT_MS, agentProgress, crewsOf, isAgentLive, jobOf, pickName, sessionProgress, spriteKind, taskVote } from './describe'
4import type { ColonyScene, Look, Route, SceneWorker, Spot } from './colony'
5import { BUILDING_OF, CROWD, FIRE_AT, MAP_TALL, SPOTS, VENUES, VILLAGE_W, countOf, nudge, playOf, pointOn, routeOf, seatAt, standsClear, strollAt, venueOf } from './colony'
6import { visibleAgents } from './svg'
7
8// Who stands where in the colony, decided in the hooks module, where it can remember: each worker's
9// task held steady, its spot kept, its walk timed. The scene it hands the drawing is plain data that
10// stays the same from one redraw to the next until something about a worker changes, so the page
11// (colony-view-19.tsx) draws again only then, and a walk under way is never restarted.
12
13// A worker as this module sees it: `job` what it was asked to do, `vote` what its current step says,
14// `look` what its state says right now, and since when, where the heartbeat says (`changedAt`).
15type Worker = { key: string; name: string; role: string; look: Look; job: Task; vote: Task | null; progress: number | null; changedAt?: number }
16
17
18// Progress for the bar over a worker's head: an exact one in eighths (a bar 18 pixels wide shows no
19// finer), a guess in quarters, since a guess creeps up with every tool call and each step would
20// redraw the colony for nothing.
21const barOf = (p: Progress | null): number | null =>
22 p === null || !Number.isFinite(p.fraction) ? null : Math.round(Math.max(0, Math.min(1, p.fraction)) * (p.isEstimate ? 4 : 8)) / (p.isEstimate ? 4 : 8)
23
24// A session's workers: its main agent, crowned and named as its captain, then its subagents.
25function workersOf(s: FleetSession, now: number, crew: Crew): Worker[] {
26 const isQuiet = s.state === 'idle' || s.state === 'ended'
27 const kind = spriteKind(s.state, s.now, s.kind)
28 const lookOf = (state: string, kind: string): Look =>
29 state === 'done' ? 'sleep' : state === 'failed' ? 'failed' : kind === 'waiting' || state === 'waiting' ? 'wait' : 'work'
30 const main: Worker = {
31 key: `${s.id}/main`,
32 name: crew.name,
33 role: 'lead',
34 look: isQuiet ? 'chill' : lookOf(s.state, kind),
35 job: jobOf(s.task),
36 vote: taskVote(jobOf(s.task), kind, s.now, true),
37 progress: isQuiet ? null : barOf(sessionProgress(s)),
38 changedAt: isQuiet ? (s.idleSince ?? undefined) : (s.turnStartedAt ?? undefined),
39 }
40 const agents = visibleAgents(s, now).map((a: FleetAgent): Worker => {
41 const heard = Math.max(a.lastAt ?? 0, a.toolAt ?? 0, a.startedAt)
42 // One running in the background keeps working while its session rests; one marked running in an
43 // ended session, or lost (long silent: its end never arrived), has stopped.
44 const hasStopped = a.state === 'working' && (s.state === 'ended' || !isAgentLive(a, now))
45 const agentKind = spriteKind(a.state, a.now, a.kind)
46 return {
47 key: `${s.id}/${a.id}`,
48 name: a.name || pickName(a.id, []),
49 role: a.type,
50 look: hasStopped ? 'chill' : lookOf(a.state, agentKind),
51 job: jobOf(a.description, a.type),
52 vote: taskVote(jobOf(a.description, a.type), agentKind, a.now, false),
53 progress: hasStopped ? null : barOf(agentProgress(a)),
54 changedAt: hasStopped ? heard + LOST_AGENT_MS : undefined,
55 }
56 })
57 return [main, ...agents]
58}
59
60// What each worker is shown doing, held steady: a change must hold a while before the worker moves,
61// so a quick command, a pause between tool calls or a short turn doesn't send it off and back. Off
62// to play only after STEADY.toChill of rest; back to work after STEADY.toWork; to another task after
63// STEADY.toTask. Waiting on the person, finishing and failing show at once.
64const STEADY = { toChill: 15_000, toWork: 3_000, toTask: 10_000 }
65type Shown = { look: Look; task: Task }
66const held = new Map<string, { shown: Shown; next?: Shown; since?: number }>()
67
68const isSame = (a: Shown, b: Shown): boolean => a.look === b.look && a.task === b.task
69const steadyFor = (from: Shown, to: Shown): number => (to.look === 'chill' ? STEADY.toChill : from.look === 'chill' ? STEADY.toWork : STEADY.toTask)
70
71// When the colony was last worked out. Nothing is decided while it isn't drawn (a closed panel), so
72// a change that has waited out its hold by then shows at once.
73let lastBuilt = 0
74
75function shownOf(wk: Worker, now: number): Shown {
76 const h = held.get(wk.key)
77 if (!h) {
78 const shown = { look: wk.look, task: wk.vote ?? wk.job }
79 held.set(wk.key, { shown })
80 return shown
81 }
82 // A step with nothing to say keeps whatever task is pending, or the one shown.
83 const wanted: Shown = { look: wk.look, task: wk.look === 'wait' ? h.shown.task : (wk.vote ?? h.next?.task ?? h.shown.task) }
84 if (isSame(wanted, h.shown)) {
85 h.next = undefined
86 return h.shown
87 }
88 const isAtOnce = wanted.look === 'wait' || h.shown.look === 'wait' || wanted.look === 'sleep' || wanted.look === 'failed' || h.shown.look === 'sleep' || h.shown.look === 'failed'
89 if (isAtOnce) {
90 h.shown = wanted
91 h.next = undefined
92 return h.shown
93 }
94 if (!h.next || !isSame(h.next, wanted)) {
95 h.next = wanted
96 // A change of state dates from when it happened, where the heartbeat says; a task's from now.
97 const isLookChange = wanted.look !== h.shown.look
98 h.since = isLookChange && wk.changedAt !== undefined ? Math.min(now, Math.max(wk.changedAt, lastBuilt)) : now
99 if (now - lastBuilt > STEADY.toChill) h.since = Math.min(h.since, now - steadyFor(h.shown, wanted))
100 }
101 if (now - (h.since ?? now) >= steadyFor(h.shown, wanted)) {
102 h.shown = wanted
103 h.next = undefined
104 }
105 return h.shown
106}
107
108// How many times the colony has been worked out, and that count when a redraw was last asked for so
109// a held change could show. If no drawing followed (the colony isn't drawn: an empty list, or the
110// list fell back to text), asking again won't help. Counts, not times: a drawing already under way
111// when the redraw was asked for is stamped earlier, yet does follow.
112let builds = 0
113let buildsWhenForced = -1
114export const noteColonyForced = (_now: number): void => {
115 buildsWhenForced = builds
116}
117
118// When the soonest held change is due to show, or null when none waits, or when the last redraw
119// asked for one drew no colony: a redraw then lets it.
120export function colonyDueAt(_now: number): number | null {
121 if (builds === buildsWhenForced) return null
122 let due: number | null = null
123 for (const h of held.values()) {
124 if (!h.next || h.since === undefined) continue
125 const at = h.since + steadyFor(h.shown, h.next)
126 due = due === null ? at : Math.min(due, at)
127 }
128 return due
129}
130
131// Where a worker goes: the building for its task; the hall to wait on the person; at play, its
132// pastime's venue (the pond, the garden, the cat) or the plaza; the tents once done.
133const placeOf = (shown: Shown, key: string): string =>
134 shown.look === 'sleep' || shown.look === 'failed' ? 'tents' : shown.look === 'chill' ? (venueOf(playOf(key))?.key ?? 'chill') : shown.look === 'wait' ? 'hall' : BUILDING_OF[shown.task]
135const venueByKey = new Map(VENUES.map(v => [v.key, v]))
136const isIdlePlace = (place: string): boolean => place === 'chill' || venueByKey.has(place)
137
138// Each worker keeps its spot at a place for as long as it stays there; a newcomer takes the first
139// free one. Numbering by order instead would shuffle everyone whenever one worker came or went.
140// A venue has only so many spots: a newcomer finding it full sits at the fire instead, and stays
141// there while it is at play, even once a spot frees up, so no one idle gets up and goes.
142type Slot = { place: string; slot: number }
143const spots = new Map<string, Slot>()
144
145function slotsFor(keys: readonly string[], wanted: readonly string[]): Slot[] {
146 const used = new Map<string, Set<number>>()
147 const usedAt = (place: string): Set<number> => {
148 const set = used.get(place) ?? new Set<number>()
149 used.set(place, set)
150 return set
151 }
152 const kept = keys.map((key, i): Slot | null => {
153 const was = spots.get(key)
154 const place = wanted[i] ?? 'chill'
155 const isOverflow = was?.place === 'chill' && venueByKey.has(place)
156 if (!was || (was.place !== place && !isOverflow) || usedAt(was.place).has(was.slot)) return null
157 usedAt(was.place).add(was.slot)
158 return was
159 })
160 return kept.map((was, i) => {
161 let slot = was
162 if (!slot) {
163 const room = venueByKey.get(wanted[i] ?? '')?.spots.length
164 let place = wanted[i] ?? 'chill'
165 let n = 0
166 while (usedAt(place).has(n)) n += 1
167 if (room !== undefined && n >= room) {
168 place = 'chill'
169 n = 0
170 while (usedAt(place).has(n)) n += 1
171 }
172 usedAt(place).add(n)
173 slot = { place, slot: n }
174 }
175 spots.set(keys[i] ?? '', slot)
176 return slot
177 })
178}
179
180// A worker's spot at a place: side by side at a door, round the plaza at play. A crowd at a door
181// wraps into more lines, toward the middle of the map, and everything keeps inside the village,
182// whatever the panel's width, so two drawings of different widths agree on where everyone is.
183// The spots at a door, in the order a crowd takes them: side by side in front of it, then more lines
184// below (every building stands above its door, so below is away from it). A door near the village's
185// edge (the forge, the tower) fans inward only. Spots that would hide a sign, stand on another
186// building or the tents, or leave the village are skipped.
187type DoorSpot = Spot
188const doorSpots = new Map<string, DoorSpot[]>()
189
190function doorSpotsOf(place: string): DoorSpot[] {
191 const known = doorSpots.get(place)
192 if (known) return known
193 const base = SPOTS[place] ?? FIRE_AT
194 const reach = 16 * Math.ceil((CROWD - 1) / 2)
195 const side = base.x + reach > VILLAGE_W - 8 ? -1 : base.x - reach < 8 ? 1 : 0
196 const offsets = side === 0 ? Array.from({ length: CROWD }, (_, i) => nudge(i)) : [0, 16, 32, 48].map(o => o * side)
197 const list: DoorSpot[] = []
198 for (let line = 0; line < 8; line += 1) {
199 for (const dx of offsets) {
200 const s = { x: base.x + dx, y: base.y + line * 22 }
201 if (s.y > MAP_TALL - 6) continue
202 if ((line === 0 && dx === 0) || standsClear(s, place)) list.push(s)
203 }
204 }
205 // A crowd bigger than the clear spots (nine testers at once) spreads wider rather than stacking.
206 for (let line = 0; list.length < 24 && line < 4; line += 1) {
207 for (const dx of [64, -64, 80, -80, 96, -96, 112, -112]) {
208 const s = { x: base.x + dx, y: base.y + line * 22 }
209 const isOnVenue = VENUES.some(v => s.x > v.box.x - 6 && s.x < v.box.x + v.box.w + 6 && s.y > v.box.y && s.y - 24 < v.box.y + v.box.h)
210 if (s.x >= 8 && s.x <= VILLAGE_W - 8 && s.y <= MAP_TALL - 6 && !isOnVenue && !list.some(t => t.x === s.x && t.y === s.y)) list.push(s)
211 }
212 }
213 doorSpots.set(place, list)
214 return list
215}
216
217function spotOf(place: string, n: number): DoorSpot {
218 if (place === 'chill') return seatAt(n)
219 const venue = venueByKey.get(place)
220 if (venue) {
221 const s = venue.spots[Math.min(n, venue.spots.length - 1)] ?? venue.at
222 return { x: s.x, y: s.y }
223 }
224 const list = doorSpotsOf(place)
225 return list[Math.min(n, list.length - 1)] ?? (SPOTS[place] ?? FIRE_AT)
226}
227
228// Where each worker was headed, from where and since when, and whether it strolls once there: the
229// same walk until its goal moves.
230type Trail = { from: Spot; to: Spot; since: number; route: Route; strolls: boolean }
231const trails = new Map<string, Trail>()
232
233function trailOf(key: string, to: Spot, now: number, start: Spot, strolls: boolean): Trail {
234 const was = trails.get(key)
235 if (was && was.to.x === to.x && was.to.y === to.y) return was
236 // A new goal: set off from wherever the drawing has it now, mid-walk, arrived, or partway round a
237 // stroll; a newcomer from `start`.
238 let from = start
239 if (was) {
240 const at = pointOn(was.route, now - was.since)
241 const off = was.strolls ? strollAt(key, now - was.since - was.route.ms, was.to) : { x: 0, y: 0 }
242 from = { x: Math.round((at.x + off.x) * 10) / 10, y: Math.round((at.y + off.y) * 10) / 10 }
243 }
244 const trail = { from, to, since: now, route: routeOf(from, to), strolls }
245 trails.set(key, trail)
246 return trail
247}
248
249// A name over the map is kept short: a long one would cover its neighbours.
250const NAME_MAX = 14
251const shortName = (name: string): string => {
252 const chars = [...name.trim()]
253 return chars.length > NAME_MAX ? `${chars.slice(0, NAME_MAX - 1).join('').trimEnd()}…` : chars.join('')
254}
255
256// At most this many workers are drawn, the busiest first: past it the drawing would outgrow the
257// app's limit (131,072 characters).
258export const MOST_WORKERS = 40
259const RANK: Readonly<Record<Look, number>> = { wait: 0, work: 1, chill: 2, failed: 3, sleep: 4 }
260
261// The colony's scene for a drawing `width` pixels wide.
262export function colonyScene(list: readonly FleetSession[], now: number, width: number, isStill: boolean): ColonyScene {
263 const crews = crewsOf(list)
264 const all = list.flatMap(s => workersOf(s, now, crews.get(s.id) ?? { name: 'Aria' }))
265 const shown = new Map(all.map(wk => [wk.key, shownOf(wk, now)]))
266 lastBuilt = now
267 builds += 1
268 const look = (wk: Worker): Look => shown.get(wk.key)?.look ?? wk.look
269 const workers = all
270 .map((wk, i) => ({ wk, i }))
271 .sort((a, b) => RANK[look(a.wk)] - RANK[look(b.wk)] || a.i - b.i)
272 .slice(0, MOST_WORKERS)
273 .sort((a, b) => a.i - b.i)
274 .map(({ wk }) => wk)
275
276 const slots = slotsFor(
277 workers.map(wk => wk.key),
278 workers.map(wk => placeOf(shown.get(wk.key) ?? { look: wk.look, task: wk.job }, wk.key)),
279 )
280 const hall = { x: SPOTS.hall?.x ?? 0, y: SPOTS.hall?.y ?? 0 }
281 const scene = workers.map((wk, i): SceneWorker => {
282 const s = shown.get(wk.key) ?? { look: wk.look, task: wk.job }
283 const { place, slot } = slots[i] ?? { place: 'chill', slot: 0 }
284 const at = spotOf(place, slot)
285 // A new subagent walks out of the hall; a main agent starts where it is.
286 const trail = trailOf(wk.key, at, now, wk.key.endsWith('/main') ? at : hall, isIdlePlace(place))
287 return {
288 key: wk.key,
289 name: shortName(wk.name),
290 role: wk.role,
291 look: s.look,
292 task: s.task,
293 progress: s.look === 'work' ? wk.progress : null,
294 x: at.x,
295 y: at.y,
296 fx: trail.from.x,
297 fy: trail.from.y,
298 since: trail.since,
299 ms: trail.route.ms,
300 }
301 })
302
303 // Forget the tasks, spots and walks of workers no longer drawn.
304 const ids = new Set(workers.map(wk => wk.key))
305 for (const key of [...trails.keys()]) if (!ids.has(key)) trails.delete(key)
306 for (const key of [...held.keys()]) if (!shown.has(key)) held.delete(key)
307 for (const key of [...spots.keys()]) if (!ids.has(key)) spots.delete(key)
308 // The line over the map counts everyone, drawn or not.
309 const count = countOf(all.map(wk => ({ ...wk, look: look(wk), task: shown.get(wk.key)?.task ?? wk.job, x: 0, y: 0, fx: 0, fy: 0, since: 0, ms: 0 })))
310 return { width, isStill, workers: scene, count }
311}
312
313// Forgets everything the scene remembers, as a reload does.
314export function forgetColony(): void {
315 held.clear()
316 spots.clear()
317 trails.clear()
318 lastBuilt = 0
319 builds = 0
320 buildsWhenForced = -1
321}
322hooks/draw.tsx 314 lines1import type { EngineInterface, RenderElement } from 'claude-code'
2
3import type { FleetLimit, FleetSession, FleetView } from '../types'
4import type { FleetSummary } from './describe'
5import { stateLine, waitText, limitName, limitTone, agentProgress, progressLabel, ago, isAgentLive, plain, taskText, recentLoops, isBusy, isHostId, isPlanLimit, resetIn, fullIn, plural, formatTokens, meter, modelName, nameOf, percentOf, progressOf, sprite, spriteKind, totalTokens } from './describe'
6import { colonySvg } from './colony'
7import { colonyScene } from './colony-scene'
8import { bandSvg, clean, ctxOf, dashboardSvg, hasAgentRows, visibleAgents, widthFor } from './svg'
9
10type Ui = ReturnType<EngineInterface['ui']['resolve']>
11
12const SVG_LIMIT = 130_000
13// All the row pictures together: a bigger fleet shows its first sessions and counts the rest.
14const TOTAL_LIMIT = 400_000
15
16// `resting`: dormant sessions left out of the list.
17export type Board = { list: FleetSession[]; sum: FleetSummary; limits: FleetLimit[]; v: FleetView; mineId: string | undefined; now: number; resting?: number }
18
19export type Actions = {
20 open: () => void
21 hideBand: () => void
22 toggle: (key: 'isCompact' | 'showColony') => void
23 // Opens the desktop app's own window for a session by its id.
24 go: (id: string) => void
25}
26
27// Every surface but the terminal draws an Svg; the terminal's table names it but draws nothing.
28const hasSvg = (ui: Ui, surface: string): ui is Ui & { Svg: NonNullable<Extract<Ui, { Svg: unknown }>['Svg']> } => surface !== 'terminal' && 'Svg' in ui
29
30const stateColor = (state: string) => (state === 'waiting' ? 'warning' : state === 'working' ? 'claude' : state === 'failed' ? 'error' : state === 'done' ? 'success' : 'inactive')
31
32// The drawing's words for a reader that can't see it; cleaned, since the app refuses an alt holding
33// a control character (a command's output can carry one). The header's are what its picture shows:
34// the counts and the plan limits (each row has its own).
35const altOf = (b: Board): string =>
36 clean(
37 [
38 `Fleet: ${plural(b.sum.sessions, 'session')}, ${b.sum.working} working, ${b.sum.waiting} need you${b.sum.agents > 0 ? `, ${plural(b.sum.agents, 'agent')}` : ''}.`,
39 ...b.limits.map(l => {
40 const resets = l.resetsAt !== null ? Date.parse(l.resetsAt) : NaN
41 return `${limitName(l.kind, true)} limit ${Math.round(l.percentUsed)}% used${Number.isFinite(resets) && resets > b.now ? `, resets in ${resetIn(resets - b.now)}` : ''}${l.fullInMs !== undefined ? `, full in about ${fullIn(l.fullInMs)} at this pace` : ''}.`
42 }),
43 ].join(' '),
44 )
45
46// A row's words: how it stands, and for one that waits, what it waits on.
47const rowAltOf = (s: FleetSession, b: Board): string => clean(`${nameOf(s)}: ${s.state === 'waiting' ? `needs you: ${plain(s.now)}` : stateLine(s, b.now)}`)
48
49// What the subagents' picture holds, counting the background loops too.
50const agentsAltOf = (s: FleetSession, b: Board): string => {
51 const loops = recentLoops(s, b.now).length
52 return clean(`${nameOf(s)}: ${[visibleAgents(s, b.now).length > 0 ? plural(visibleAgents(s, b.now).length, 'agent') : '', loops > 0 ? plural(loops, 'background loop') : ''].filter(Boolean).join(', ')}`)
53}
54
55// One button per other session that waits on the person and is a desktop one (a terminal has no
56// app window to open).
57function openers(ui: Ui, b: Board, act: Actions): RenderElement | null {
58 const waiting = b.list.filter(s => s.state === 'waiting' && s.hostId !== undefined && isHostId(s.hostId) && s.id !== b.mineId)
59 if (waiting.length === 0) return null
60 const { Box, Button } = ui
61 return (
62 <Box flexDirection="column" marginTop={1}>
63 {waiting.map((s, i) => {
64 const wait = waitText(s, b.now)
65 return <Button key={`go-${s.id}`} plain hotkey={i < 9 ? String(i + 1) : undefined} label={clean(`${nameOf(s)} needs you${wait ? ` ${wait}` : ''} · Open`)} onPress={() => act.go(s.id)} />
66 })}
67 </Box>
68 )
69}
70
71function controls(ui: Ui, b: Board, act: Actions, isDesktop: boolean): RenderElement {
72 const { Box, Button } = ui
73 return (
74 <Box flexDirection="row" columnGap={2} marginTop={isDesktop ? 0 : 1}>
75 {isDesktop && <Button key="colony" plain dimColor label={b.v.showColony === false ? 'Show colony' : 'Hide colony'} onPress={() => act.toggle('showColony')} />}
76 <Button key="compact" plain dimColor label={b.v.isCompact ? 'Detailed' : 'Compact'} onPress={() => act.toggle('isCompact')} />
77 </Box>
78 )
79}
80
81// The pictures a desktop dashboard is made of: the header, and one for each session's row. Each is held under the app's limit
82// on an Svg (131,072 characters, or it goes blank). One mode serves every row, the poorest any row
83// needs: as set, compact, then compact with at most three subagent rows. The rows together are held
84// to TOTAL_LIMIT: past it the rest of the sessions are counted, not drawn. Null when a row is too big
85// even in the smallest mode.
86type Card = { source: string; height: number }
87type RowCards = { main: Card; agents: Card | null }
88export function fittingCards(b: Board, width: number): { header: Card; rows: RowCards[]; hidden: number; mode: number } | null {
89 const input = { list: b.list, sum: b.sum, limits: b.limits, mineId: b.mineId, now: b.now, isCompact: b.v.isCompact, isStill: b.v.isStill }
90 const header = dashboardSvg({ ...input, part: 'header' }, width)
91 if (header.source.length > SVG_LIMIT) return null
92 const modes = [input, { ...input, isCompact: true }, { ...input, isCompact: true, agentCap: 3 }]
93 const cardsOf = (x: (typeof modes)[number], i: number): RowCards => {
94 // A row with subagents is two pictures.
95 const s = b.list[i]
96 const isSplit = s !== undefined && hasAgentRows(s, b.now)
97 return { main: dashboardSvg({ ...x, part: isSplit ? { main: i } : i }, width), agents: isSplit ? dashboardSvg({ ...x, part: { agents: i } }, width) : null }
98 }
99 for (let m = 0; m < modes.length; m += 1) {
100 const x = modes[m] ?? input
101 const all = b.list.map((_, i) => cardsOf(x, i))
102 if (all.some(c => c.main.source.length > SVG_LIMIT || (c.agents?.source.length ?? 0) > SVG_LIMIT)) continue
103 // Every row fits in this mode, the first one that does; then only the total is left to hold.
104 const rows: RowCards[] = []
105 let total = 0
106 for (const c of all) {
107 const size = c.main.source.length + (c.agents?.source.length ?? 0)
108 if (total + size > TOTAL_LIMIT) break
109 total += size
110 rows.push(c)
111 }
112 return { header, rows, hidden: b.list.length - rows.length, mode: m }
113 }
114 return null
115}
116
117// The colony under the header, a drawing of its own. On the desktop it is a Client: the page keeps
118// that region across the panel's redraws and draws it again only when the scene changes, so a walk
119// is never restarted. Elsewhere it is an Svg drawn with the panel.
120function colony(ui: Ui, b: Board, width: number): RenderElement | null {
121 if (b.v.showColony === false || b.list.length === 0) return null
122 const scene = colonyScene(b.list, b.now, width, b.v.isStill)
123 if ('Client' in ui) {
124 const { Client } = ui
125 // The page keeps a surface module it has loaded, even across a plugin reload, so a colony change
126 // would never reach an open panel. The module's file and key carry the release, so a new one
127 // loads fresh: rename both together whenever the colony's drawing changes.
128 return <Client key="colony-19" module="./colony-view-19.tsx" props={scene} />
129 }
130 if (!('Svg' in ui)) return null
131 const drawing = colonySvg(scene, b.now)
132 const { Svg } = ui
133 return drawing.source.length <= SVG_LIMIT ? <Svg source={drawing.source} alt={drawing.alt} width={width} height={drawing.height} /> : null
134}
135
136// What the list leaves out, under it: the sessions put away for resting too long.
137const restingNote = (ui: Ui, b: Board): RenderElement | null => {
138 const { Text } = ui
139 return b.resting ? <Text dimColor>{`${plural(b.resting, 'session')} idle over 12h hidden · /fleet ended shows them`}</Text> : null
140}
141
142// The full dashboard: an SVG card where the surface draws one, aligned text on the terminal. The colony
143// sits under the header, so it is in view when the panel opens however long the list; the list scrolls
144// with the panel.
145export function dashboard(ui: Ui, surface: string, b: Board, act: Actions, columns: number, frame: number): RenderElement {
146 const { Box, Text } = ui
147 if (hasSvg(ui, surface)) {
148 const width = widthFor(columns, 360, 1600)
149 const cards = fittingCards(b, width)
150 const { Svg } = ui
151 // A fleet too big to draw within the app's limit even in its smallest form falls through to text.
152 if (cards) {
153 return (
154 <Box flexDirection="column">
155 <Svg key="head" source={cards.header.source} alt={altOf(b)} width={width} height={cards.header.height} />
156 {colony(ui, b, width)}
157 {cards.rows.map((card, i) => {
158 const s = b.list[i]
159 if (!s) return null
160 return (
161 <Box key={`r-${s.id}`} flexDirection="column">
162 <Svg key={`row-${s.id}`} source={card.main.source} alt={rowAltOf(s, b)} width={width} height={card.main.height} />
163 {card.agents ? <Svg key={`agents-${s.id}`} source={card.agents.source} alt={agentsAltOf(s, b)} width={width} height={card.agents.height} /> : null}
164 </Box>
165 )
166 })}
167 {cards.hidden > 0 && <Text dimColor>+{cards.hidden} more sessions</Text>}
168 {restingNote(ui, b)}
169 {openers(ui, b, act)}
170 {controls(ui, b, act, true)}
171 </Box>
172 )
173 }
174 }
175
176 const row = (s: FleetSession) => {
177 const ctx = ctxOf(s)
178 const agents = visibleAgents(s, b.now)
179 const quiet = s.state === 'idle' || s.state === 'ended'
180 const tokens = totalTokens(s.tokens)
181 const stats = [s.costUsd !== null ? `$${s.costUsd.toFixed(2)}` : '', tokens > 0 ? `${formatTokens(tokens)} tok` : '', s.toolsTotal > 0 ? `${s.toolsTotal} tools` : ''].filter(Boolean).join(' · ')
182 return (
183 <Box key={`s-${s.id}`} flexDirection="row" marginBottom={b.v.isCompact ? 0 : 1}>
184 <Box width={11} flexShrink={0}>
185 <Text color={stateColor(s.state)}>{sprite(spriteKind(s.state, s.now, s.kind), frame, b.v.isStill)}</Text>
186 </Box>
187 <Box flexDirection="column" flexGrow={1}>
188 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
189 <Text wrap="truncate-end">
190 <Text bold dimColor={quiet}>
191 {nameOf(s)}
192 </Text>
193 <Text dimColor>
194 {s.id === b.mineId ? ' this session' : ''} {modelName(s.model)}
195 {s.effort ? ` ${s.effort}` : ''}
196 </Text>
197 </Text>
198 <Text color={s.state === 'waiting' ? 'warning' : s.state === 'working' ? 'claude' : 'inactive'}>
199 {stateLine(s, b.now)}
200 </Text>
201 </Box>
202 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
203 <Text color={s.state === 'waiting' ? 'warning' : undefined} dimColor={quiet} wrap="truncate-end">
204 {s.state === 'idle' && s.last ? `Replied: ${plain(s.last)}` : plain(s.now)}
205 </Text>
206 <Text color={ctx >= 0.85 ? 'error' : ctx >= 0.7 ? 'warning' : 'inactive'}>
207 {meter(ctx, 8)} {Math.round(ctx * 100)}%
208 </Text>
209 </Box>
210 {!b.v.isCompact && (taskText(s) !== '' || stats !== '') && (
211 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
212 <Text dimColor wrap="truncate-end">
213 {taskText(s) ? `Asked: ${taskText(s)}` : ''}
214 </Text>
215 <Text dimColor>{stats}</Text>
216 </Box>
217 )}
218 {agents.map((a, i) => (
219 <Box key={`a-${s.id}-${a.id}`} flexDirection="row" columnGap={1}>
220 <Text dimColor>{i === agents.length - 1 ? '└' : '├'}</Text>
221 <Text color={stateColor(a.state === 'working' ? 'working' : a.state)}>{sprite(spriteKind(a.state, a.now, a.kind), frame + i + 1, b.v.isStill)}</Text>
222 <Text wrap="truncate-end">
223 <Text bold>{a.name || a.type}</Text>
224 <Text dimColor> {a.name ? `${a.type} ` : ''}{modelName(a.model)} </Text>
225 {a.state === 'done' ? 'done' : plain(a.now)}
226 <Text dimColor>
227 {' '}
228 {agentProgress(a) !== null ? `${progressLabel(agentProgress(a))} · ` : ''}
229 {ago((a.endedAt ?? b.now) - a.startedAt)}
230 {totalTokens(a.tokens) > 0 ? ` · ${formatTokens(totalTokens(a.tokens))} tok` : ''}
231 </Text>
232 </Text>
233 </Box>
234 ))}
235 </Box>
236 </Box>
237 )
238 }
239
240 return (
241 <Box flexDirection="column">
242 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
243 <Text bold>Fleet</Text>
244 <Text dimColor>{plural(b.sum.sessions, 'session')}</Text>
245 {b.sum.working > 0 ? <Text color="claude">{b.sum.working} working</Text> : <Text dimColor>none working</Text>}
246 {b.sum.waiting > 0 && <Text color="warning">{plural(b.sum.waiting, 'needs', 'need')} you</Text>}
247 {b.sum.agents > 0 && <Text dimColor>{plural(b.sum.agents, 'agent')}</Text>}
248 <Text dimColor>${b.sum.costUsd.toFixed(2)} {b.limits.some(isPlanLimit) ? 'API value' : 'spent'}</Text>
249 {b.limits.map(limit => {
250 const resets = limit.resetsAt !== null ? Date.parse(limit.resetsAt) : NaN
251 return (
252 <Text key={`limit-${limit.kind}`} color={limitTone(limit)}>
253 {limitName(limit.kind)} {Math.round(limit.percentUsed)}%
254 {limit.fullInMs !== undefined ? ` · full ~${fullIn(limit.fullInMs)}` : Number.isFinite(resets) && resets > b.now ? ` · ${resetIn(resets - b.now)}` : ''}
255 </Text>
256 )
257 })}
258 </Box>
259 <Box flexDirection="column" marginTop={1}>
260 {b.list.length === 0 ? <Text dimColor>No sessions yet. Sessions started after the fleet mod was installed appear here.</Text> : b.list.map(row)}
261 </Box>
262 {restingNote(ui, b)}
263 {openers(ui, b, act)}
264 {controls(ui, b, act, false)}
265 </Box>
266 )
267}
268
269// The band above the prompt: busy sessions in a row, idle ones folded into a count.
270export function band(ui: Ui, surface: string, b: Board, act: Actions, bodyColumns: number, frame: number): RenderElement | null {
271 const live = b.list.filter(s => s.state !== 'ended')
272 const agents = b.sum.agents
273 if (live.length <= 1 && agents === 0) return null
274 const { Box, Button, Text } = ui
275
276 if (hasSvg(ui, surface)) {
277 const strip = bandSvg(live, b.mineId, widthFor(bodyColumns, 280, 1300) - 90, b.now, b.v.isStill)
278 const { Svg } = ui
279 return (
280 <Box flexDirection="row" alignItems="center" columnGap={1}>
281 <Svg source={strip.source} alt={altOf(b)} width={strip.width} height={strip.height} />
282 <Button key="fleet-open" plain dimColor label="Fleet" onPress={act.open} />
283 <Button key="fleet-hide" plain dimColor role="dismiss" label="×" onPress={act.hideBand} />
284 </Box>
285 )
286 }
287
288 const busy = live.filter(s => isBusy(s, b.now)).slice(0, 3)
289 const idle = live.filter(s => s.state === 'idle' && !isBusy(s, b.now)).length
290 return (
291 <Box flexDirection="column">
292 <Box flexDirection="row" columnGap={2}>
293 <Text dimColor>Fleet</Text>
294 {b.sum.working > 0 && <Text color="claude">{b.sum.working} working</Text>}
295 {b.sum.waiting > 0 && <Text color="warning">{plural(b.sum.waiting, 'needs', 'need')} you</Text>}
296 {agents > 0 && <Text dimColor>{plural(agents, 'agent')}</Text>}
297 {idle > 0 && <Text dimColor>{idle} idle</Text>}
298 <Button key="fleet-open" plain dimColor label="Details" onPress={act.open} />
299 <Button key="fleet-hide" plain dimColor label="×" onPress={act.hideBand} />
300 </Box>
301 {busy.map(s => (
302 <Box key={`band-${s.id}`} flexDirection="row" columnGap={1}>
303 <Text color={stateColor(s.state)}>{sprite(spriteKind(s.state, s.now, s.kind), frame, b.v.isStill)}</Text>
304 <Text bold>{nameOf(s)}</Text>
305 <Text color={s.state === 'waiting' ? 'warning' : undefined} dimColor={s.state !== 'waiting'} wrap="truncate-end">
306 {percentOf(progressOf(s.tasks)) !== null ? `${percentOf(progressOf(s.tasks))}% · ` : ''}
307 {s.state === 'idle' && s.agents.some(a => isAgentLive(a, b.now)) ? `${plural(s.agents.filter(a => isAgentLive(a, b.now)).length, 'agent')} working` : plain(s.now)}
308 </Text>
309 </Box>
310 ))}
311 </Box>
312 )
313}
314hooks/colony.ts 1152 lines1import type { Task } from './describe'
2import { THEME, PAD, clean, escape, measure, roleOf } from './svg'
3
4// The colony: one village for the whole fleet, seen from above in pixel art, under the list.
5// Each building is a general task, marked by a hanging sign with a picture, never a word: a hammer
6// at the workshop (develop), a book at the library (research), a lens at the watchtower (review),
7// a flask at the forge (test and build), a scroll at the town hall (plan, and waiting on the person),
8// a bug at the mine (debug). The words are said once, in the line over the map. A worker works at
9// the building for its task, holding that task's tool; one with nothing to do plays round the fire;
10// a finished subagent sleeps by the tents. It is for fun: each worker shows only its name.
11//
12// This module only draws: a scene in, an SVG out. It runs in the hooks module and, on the desktop,
13// in the page itself (colony-view-19.tsx), so a redraw of the panel never restarts a walk.
14// colony-scene.ts decides who stands where.
15
16export const P = 2 // one art pixel, in map units
17const OUT = '#2b201b' // every sprite's outline
18const HEAD = 26
19export const MAP_TALL = 198 // map units
20export const VILLAGE_W = 320 // the village's own width; wider maps get forest either side
21
22type Colors = Readonly<Record<string, string>>
23export type Spot = { x: number; y: number }
24type Box = { x: number; y: number; w: number; h: number }
25type Art = { grid: readonly string[]; colors: Colors }
26
27// A grid of art pixels at (x, y), outlined: each letter a colour, '.' clear. Every clear cell
28// beside a filled one takes the outline colour. Runs of one colour become one path, kept short (the
29// app refuses a drawing past 131,072 characters): each run a line one art pixel thick along its row,
30// each after the first placed from the end of the last.
31function sprite(grid: readonly string[], colors: Colors, x: number, y: number, isOutlined = true): string {
32 const H = grid.length
33 const W = Math.max(...grid.map(row => row.length))
34 const filled = (r: number, c: number): boolean => r >= 0 && r < H && c >= 0 && colors[grid[r]?.[c] ?? '.'] !== undefined
35 const isEdge = (r: number, c: number): boolean => !filled(r, c) && (filled(r - 1, c) || filled(r + 1, c) || filled(r, c - 1) || filled(r, c + 1))
36 const runs = new Map<string, { d: string; x: number; y: number }>()
37 const n = (v: number) => +v.toFixed(2)
38 const run = (r: number, c: number, end: number, color: string) => {
39 const at = { x: x + c * P, y: y + r * P + P / 2 }
40 const was = runs.get(color)
41 const move = was ? `m${n(at.x - was.x)} ${n(at.y - was.y)}` : `M${n(at.x)} ${n(at.y)}`
42 runs.set(color, { d: `${was?.d ?? ''}${move}h${(end - c) * P}`, x: at.x + (end - c) * P, y: at.y })
43 }
44 if (isOutlined) {
45 for (let r = -1; r <= H; r += 1) {
46 let c = -1
47 while (c <= W) {
48 if (!isEdge(r, c)) {
49 c += 1
50 continue
51 }
52 let end = c
53 while (end <= W && isEdge(r, end)) end += 1
54 run(r, c, end, OUT)
55 c = end
56 }
57 }
58 }
59 grid.forEach((row, r) => {
60 let c = 0
61 while (c < row.length) {
62 const color = colors[row[c] ?? '.']
63 if (!color) {
64 c += 1
65 continue
66 }
67 let end = c
68 while (end < row.length && row[end] === row[c]) end += 1
69 run(r, c, end, color)
70 c = end
71 }
72 })
73 return `<g fill="none" stroke-width="${P}">${[...runs].map(([color, path]) => `<path stroke="${color}" d="${path.d}"/>`).join('')}</g>`
74}
75
76// Scenery drawn many times (trees, bushes, tents) is defined once and placed with <use>.
77const useArt = (id: string, x: number, y: number): string => `<use href="#${id}" x="${x}" y="${y}"/>`
78
79// --- Buildings, drawn from a few numbers: a stepped gable roof over a wall with a door and windows. ---
80
81type House = { w: number; roof: number; wall: number; door: number; doorW?: number; doorH?: number; windows: number[]; chimney?: number }
82
83function houseGrid(h: House): string[] {
84 const extra = h.chimney !== undefined ? 3 : 0
85 const rows = Array.from({ length: extra + h.roof + h.wall }, () => Array.from({ length: h.w }, () => '.'))
86 const set = (r: number, c: number, ch: string) => {
87 const row = rows[r]
88 if (row && c >= 0 && c < h.w) row[c] = ch
89 }
90 for (let i = 0; i < h.roof; i += 1) {
91 const inset = h.roof - 1 - i
92 for (let c = inset; c < h.w - inset; c += 1) set(extra + i, c, i === 0 ? 'L' : i === h.roof - 1 ? 'D' : c === inset ? 'L' : 'R')
93 }
94 if (h.chimney !== undefined) {
95 for (let r = 0; r < extra + 2; r += 1) {
96 set(r, h.chimney, r === 0 ? 'c' : 'C')
97 set(r, h.chimney + 1, r === 0 ? 'c' : 'C')
98 }
99 }
100 const top = extra + h.roof
101 for (let r = 0; r < h.wall; r += 1) for (let c = 1; c < h.w - 1; c += 1) set(top + r, c, r === h.wall - 1 ? 'w' : 'W')
102 const dw = h.doorW ?? 4
103 const dh = h.doorH ?? 5
104 const d0 = h.door - Math.floor(dw / 2)
105 for (let r = 0; r < dh; r += 1) for (let c = 0; c < dw; c += 1) set(top + h.wall - dh + r, d0 + c, r === 0 ? 'f' : 'd')
106 for (const wx of h.windows) {
107 for (let r = 0; r < 4; r += 1) for (let c = 0; c < 4; c += 1) set(top + 2 + r, wx + c, r === 0 || r === 3 || c === 0 || c === 3 ? 'f' : 'g')
108 }
109 return rows.map(row => row.join(''))
110}
111
112const CREAM = { W: '#e6d8b8', w: '#bba982', d: '#5a3a26', f: '#3f2a1c', g: '#f3d26b' }
113const HALL: Art = { grid: houseGrid({ w: 30, roof: 8, wall: 12, door: 15, doorW: 6, doorH: 6, windows: [4, 22] }), colors: { ...CREAM, L: '#d06a4e', R: '#b5523b', D: '#7f3527' } }
114const LIBRARY: Art = { grid: houseGrid({ w: 22, roof: 7, wall: 10, door: 11, windows: [3, 15] }), colors: { ...CREAM, L: '#7a9bd0', R: '#4f6fa8', D: '#33497a' } }
115const WORKSHOP: Art = { grid: houseGrid({ w: 22, roof: 6, wall: 10, door: 8, windows: [14] }), colors: { W: '#c48c5c', w: '#9a6a42', d: '#4a2f1f', f: '#3f2a1c', g: '#f3d26b', L: '#b07a50', R: '#8b5a3c', D: '#5e3a25' } }
116const FORGE: Art = { grid: houseGrid({ w: 20, roof: 6, wall: 10, door: 13, windows: [3], chimney: 4 }), colors: { W: '#b7b0a2', w: '#8e877b', d: '#2b201b', f: '#3f2a1c', g: '#f08a3c', L: '#8a94a3', R: '#5d6675', D: '#3d4450', C: '#7a726c', c: '#4a4440' } }
117const TOWER: Art = {
118 grid: [
119 '....LR....', '...LRRR...', '...LRRR...', '..LRRRRR..', '..LRRRRR..', '.LRRRRRRR.', 'DDDDDDDDDD',
120 '.WWWWWWWW.', '.WWWffWWW.', '.WWWggWWW.', '.WWWggWWW.', '.WWWWWWWW.', '.WWWWWWWW.', '.WWWffWWW.',
121 '.WWWggWWW.', '.WWWggWWW.', '.WWWWWWWW.', '.WWWffWWW.', '.WWWddWWW.', '.WWWddWWW.', '.wwwddwww.',
122 ],
123 colors: { L: '#6fbfaa', R: '#3f8f7c', D: '#2b6656', W: '#c4bdb0', w: '#8e877b', f: '#3f2a1c', g: '#f3d26b', d: '#5a3a26' },
124}
125const MINE: Art = {
126 grid: [
127 '......HHHHH...........', '....HHKKKKKKHH........', '...HKKKKKKKKKKKH......', '..HKKKKKkkKKKKKKKH....',
128 '.HKKKKTTTTTTKKKKKKkH..', '.KKKKKTddddTKKKKKkkk..', 'KKKKKKTddddTKKKKkkkkk.', 'KKKKKKTddddTKKKkkkkkkk', 'kkkkkkTddddTkkkkkkkkkk',
129 ],
130 colors: { H: '#b3aea5', K: '#8f8a82', k: '#6f6a63', T: '#8b5a3c', d: '#1f1814' },
131}
132const FLAG: Art = { grid: ['pFFFF', 'pFFFF', 'pFF..', 'p....', 'p....', 'p....'], colors: { p: '#5a3a26', F: '#e08a68' } }
133const ANVIL: Art = { grid: ['.AAAAAA', 'AAAAAA.', '..AA...', '.AAAA..'], colors: { A: '#5b6270' } }
134const BARREL: Art = { grid: ['.NNN.', 'NnnnN', 'NNNNN', 'NnnnN', '.NNN.'], colors: { N: '#8b5a3c', n: '#6b4029' } }
135const CART: Art = { grid: ['MmmmmM', 'MMMMMM', '.o..o.'], colors: { M: '#6f7680', m: '#c9a26b', o: '#2b201b' } }
136const BOOK: Art = { grid: ['kkKkk', 'kkKkk', 'kkKkk'], colors: { k: '#f4ebd0', K: '#4f6fa8' } }
137const TENT: Art = { grid: ['....E....', '...EEe...', '..EEEee..', '.EEdddee.', 'EEEdddeee'], colors: { E: '#ece1c6', e: '#c6b48e', d: '#5a3a26' } }
138const FIRE = { a: ['...o....', '..oyo...', '..oyyo..', '.SoyyoS.', 'SNNNNNNS', '.SSSSSS.'], b: ['....o...', '...oyo..', '..oyyo..', '.SoyyoS.', 'SNNNNNNS', '.SSSSSS.'], colors: { S: '#8e877b', N: '#7a4f33', o: '#f08a3c', y: '#f7d154' } }
139const BENCH_H: Art = { grid: ['NNNNNNN', 'nnnnnnn'], colors: { N: '#9a6a42', n: '#6b4029' } }
140const BENCH_V: Art = { grid: ['Nn', 'Nn', 'Nn', 'Nn', 'Nn'], colors: { N: '#9a6a42', n: '#6b4029' } }
141const TREE: Art = {
142 grid: ['...HHHH....', '..HHGGGGG..', '.HHGGGGGGg.', '.HGGGGGGGg.', 'HGGGGGGGGgg', 'GGGGGGGGggg', '.GGGGGGggg.', '..ggggggg..', '....TTt....', '....TTt....'],
143 colors: { H: '#7bb35e', G: '#4f8a43', g: '#386a33', T: '#7a4f33', t: '#5a3a26' },
144}
145const PINE: Art = {
146 grid: ['.....H.....', '....HGg....', '...HGGGg...', '....GGg....', '...HGGGg...', '..HGGGGGg..', '...GGGGg...', '..HGGGGGg..', '.HGGGGGGGg.', 'HGGGGGGGGgg', '....TTt....', '....TTt....'],
147 colors: { H: '#5f9a55', G: '#3d7542', g: '#2b5732', T: '#7a4f33', t: '#5a3a26' },
148}
149const BUSH: Art = { grid: ['.HGG.', 'HGGGg', 'GGGgg', '.ggg.'], colors: { H: '#7bb35e', G: '#4f8a43', g: '#386a33' } }
150
151const GRASS = '#5b8f4c'
152const DIRT = '#c8a46e'
153const DIRT_RIM = '#a8844f'
154
155// --- Signs: a picture of each task on a small board, hung from the wall or stood on a post. ---
156
157const WOOD = '#7a4f33'
158const STEEL = '#6f7680'
159const SIGN_ICONS: Readonly<Record<Task, Art>> = {
160 develop: { grid: ['.sssss.', '.SSSSS.', '...N...', '...N...', '...N...', '...N...', '...N...'], colors: { s: '#9aa2ad', S: STEEL, N: WOOD } },
161 research: { grid: ['.......', 'SKKKKKp', 'SKyyyKp', 'SKKKKKp', 'SKKKKKp', 'SKKKKKp', '.......'], colors: { S: '#33497a', K: '#4f6fa8', y: '#e8c46a', p: '#fbf6e6' } },
162 review: { grid: ['.SSS...', 'SgggS..', 'SgggS..', 'SgggS..', '.SSSN..', '....NN.', '.....NN'], colors: { S: '#4a4440', g: '#9fd3ea', N: WOOD } },
163 test: { grid: ['..CCC..', '...g...', '...g...', '..gLg..', '.gLLLg.', 'gLLLLLg', '.ggggg.'], colors: { C: '#8b5a3c', g: '#cfe9f2', L: '#5fb57a' } },
164 plan: { grid: ['NNNNNNN', '.ppppp.', '.plllp.', '.ppppp.', '.pllpp.', '.ppppp.', 'NNNNNNN'], colors: { N: '#9a6a42', p: '#fbf6e6', l: '#8b6a4a' } },
165 debug: { grid: ['.k...k.', '..kRk..', '.RRRRR.', 'kRkRkRk', '.RRRRR.', 'kRRkRRk', '..R.R..'], colors: { R: '#d7665d', k: OUT } },
166}
167const BOARD: Art = { grid: Array.from({ length: 9 }, () => 'bbbbbbbbb'), colors: { b: '#e9d6a8' } }
168const BOARD_SIZE = 9 * P
169
170// A task's sign with its board's top-left at (x, y): hung by two chains from an iron arm that
171// runs from the wall at `wallX`, or, with no wall to hang from, stood on a post.
172function signSvg(task: Task, x: number, y: number, wallX: number | null): string {
173 const icon = SIGN_ICONS[task]
174 const mount =
175 wallX === null
176 ? `<rect x="${x + BOARD_SIZE / 2 - 1.5}" y="${y + BOARD_SIZE}" width="3" height="10" fill="${OUT}"/><rect x="${x + BOARD_SIZE / 2 - 0.5}" y="${y + BOARD_SIZE}" width="1" height="9" fill="${WOOD}"/>`
177 : `<rect x="${Math.min(wallX, x) - 1}" y="${y - 5}" width="${Math.abs(x + BOARD_SIZE / 2 - wallX) + BOARD_SIZE / 2 + 2}" height="2" fill="#3d3530"/><rect x="${x + 3}" y="${y - 3}" width="1" height="3" fill="#3d3530"/><rect x="${x + BOARD_SIZE - 4}" y="${y - 3}" width="1" height="3" fill="#3d3530"/>`
178 return mount + sprite(BOARD.grid, BOARD.colors, x, y) + sprite(icon.grid, icon.colors, x + P, y + P, false)
179}
180
181// --- The village plan, in map units with the village's left edge at 0. ---
182
183// Each building, the task it houses, and its sign: the board's top-left from the building's corner,
184// and the wall its arm leaves from (null: a post). Signs hang clear of the doors workers crowd.
185export const BUILDINGS: readonly { key: string; task: Task; x: number; y: number; art: Art; sign: Spot; wall: number | null }[] = [
186 { key: 'hall', task: 'plan', x: 130, y: 12, art: HALL, sign: { x: -22, y: 22 }, wall: 2 },
187 { key: 'library', task: 'research', x: 42, y: 30, art: LIBRARY, sign: { x: -22, y: 20 }, wall: 2 },
188 { key: 'workshop', task: 'develop', x: 236, y: 32, art: WORKSHOP, sign: { x: 48, y: 18 }, wall: 42 },
189 { key: 'tower', task: 'review', x: 6, y: 76, art: TOWER, sign: { x: 24, y: 4 }, wall: 18 },
190 { key: 'forge', task: 'test', x: 268, y: 88, art: FORGE, sign: { x: -32, y: 24 }, wall: 2 },
191 { key: 'mine', task: 'debug', x: 206, y: 128, art: MINE, sign: { x: 50, y: 10 }, wall: null },
192]
193export const BUILDING_OF = Object.fromEntries(BUILDINGS.map(b => [b.task, b.key])) as Readonly<Record<Task, string>>
194
195// Where a worker stands for each kind of work (in front of the door), and the plaza's fire.
196export const SPOTS: Readonly<Record<string, Spot>> = {
197 hall: { x: 160, y: 60 }, library: { x: 64, y: 72 }, workshop: { x: 252, y: 70 }, tower: { x: 16, y: 126 },
198 forge: { x: 296, y: 130 }, mine: { x: 226, y: 152 }, tents: { x: 82, y: 160 },
199}
200export const FIRE_AT: Spot = { x: 160, y: 104 }
201// Spots round the plaza for those with nothing to do, front ones first, spread wide enough that
202// their names don't meet; past these, a second ring further out.
203const SEATS: readonly Spot[] = [
204 { x: 128, y: 114 }, { x: 192, y: 114 }, { x: 146, y: 132 }, { x: 176, y: 132 }, { x: 116, y: 98 }, { x: 204, y: 98 }, { x: 140, y: 86 }, { x: 182, y: 86 },
205]
206
207// Workers sharing a spot stand side by side: 0, +16, -16, +32...; past CROWD a new line starts.
208export const CROWD = 7
209export const nudge = (i: number): number => (i === 0 ? 0 : (i % 2 === 1 ? 1 : -1) * Math.ceil(i / 2) * 16)
210
211// The ground a building (with its sign) stands on, in village units: no one is seated there.
212const FOOTPRINTS: readonly Box[] = BUILDINGS.flatMap(b => [
213 { x: b.x - 4, y: b.y - 4, w: Math.max(...b.art.grid.map(r => r.length)) * P + 8, h: b.art.grid.length * P + 12 },
214 { x: b.x + b.sign.x - 2, y: b.y + b.sign.y - 6, w: 9 * P + 4, h: 9 * P + 18 },
215])
216// Whether a worker standing at a door's spot (its body about 12 wide and 24 tall above its feet)
217// keeps clear of every sign, of every building but its own, and of the tents, and stays inside the
218// village: a crowd at a door must not hide the signs that say what each building is for.
219const TENTS: readonly Box[] = [{ x: 58, y: 132, w: 22, h: 14 }, { x: 82, y: 138, w: 22, h: 14 }]
220
221// Little places to while away the time, along the foot of the village, each with a spot or two:
222// a pond to fish in, a garden patch to water, and the village cat, to pet. `box` is the ground it
223// takes, its idlers included: no one else is seated or crowds there. A spot that faces left flips.
224export type VenueSpot = Spot & { flip?: boolean }
225export type Venue = { key: string; play: 'fish' | 'water' | 'pet'; at: Spot; spots: readonly VenueSpot[]; box: Box }
226export const VENUES: readonly Venue[] = [
227 { key: 'pond', play: 'fish', at: { x: 218, y: 182 }, spots: [{ x: 186, y: 186 }, { x: 250, y: 186, flip: true }], box: { x: 178, y: 160, w: 78, h: 34 } },
228 { key: 'garden', play: 'water', at: { x: 117, y: 181 }, spots: [{ x: 94, y: 186 }, { x: 140, y: 186, flip: true }], box: { x: 86, y: 162, w: 62, h: 32 } },
229 { key: 'cat', play: 'pet', at: { x: 60, y: 186 }, spots: [{ x: 44, y: 188 }, { x: 76, y: 188, flip: true }], box: { x: 36, y: 164, w: 48, h: 30 } },
230]
231export const venueOf = (play: string): Venue | undefined => VENUES.find(v => v.play === play)
232
233const overlaps = (a: Box, b: Box): boolean => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
234export function standsClear(s: Spot, own: string): boolean {
235 if (s.x < 8 || s.x > VILLAGE_W - 8 || s.y > MAP_TALL - 6) return false
236 const body = { x: s.x - 6, y: s.y - 24, w: 12, h: 24 }
237 const signs = BUILDINGS.map(b => ({ x: b.x + b.sign.x, y: b.y + b.sign.y - 6, w: BOARD_SIZE, h: BOARD_SIZE + (b.wall === null ? 16 : 6) }))
238 const walls = BUILDINGS.filter(b => b.key !== own).map(b => ({ x: b.x, y: b.y, w: Math.max(...b.art.grid.map(r => r.length)) * P, h: b.art.grid.length * P }))
239 const venues = VENUES.filter(v => v.key !== own).map(v => v.box)
240 return ![...signs, ...walls, ...venues, ...(own === 'tents' ? [] : TENTS)].some(box => overlaps(body, box))
241}
242
243const isClear = (s: Spot): boolean =>
244 s.x >= 10 && s.x <= VILLAGE_W - 10 && s.y >= 30 && s.y <= MAP_TALL - 8 && ![...FOOTPRINTS, ...VENUES.map(v => v.box)].some(f => s.x > f.x - 6 && s.x < f.x + f.w + 6 && s.y > f.y && s.y < f.y + f.h + 22)
245
246// Seats for those at play: the eight round the fire first, then spots on a grid spreading out from
247// the plaza, nearest first, leaving out any on a building, a sign or off the map. The nth worker at
248// play takes the nth; each seat is handed out once.
249const SEAT_LIST: Spot[] = (() => {
250 const grid: Spot[] = []
251 for (let gx = -12; gx <= 12; gx += 1) for (let gy = -6; gy <= 6; gy += 1) grid.push({ x: FIRE_AT.x + gx * 18, y: FIRE_AT.y + gy * 15 })
252 const near = grid.filter(s => Math.hypot(s.x - FIRE_AT.x, s.y - FIRE_AT.y) > 34).sort((a, b) => Math.hypot((a.x - FIRE_AT.x) / 1.6, a.y - FIRE_AT.y) - Math.hypot((b.x - FIRE_AT.x) / 1.6, b.y - FIRE_AT.y))
253 const list: Spot[] = []
254 for (const seat of [...SEATS, ...near]) if (isClear(seat) && !list.some(t => Math.abs(t.x - seat.x) < 14 && Math.abs(t.y - seat.y) < 12)) list.push(seat)
255 return list
256})()
257export function seatAt(i: number): Spot {
258 return SEAT_LIST[Math.min(i, SEAT_LIST.length - 1)] ?? FIRE_AT
259}
260
261// A filled ellipse in art-pixel rows, for the plaza.
262function blob(cx: number, cy: number, rx: number, ry: number, color: string): string {
263 let d = ''
264 for (let r = -ry; r <= ry; r += 1) {
265 const half = Math.round(rx * Math.sqrt(Math.max(0, 1 - (r / (ry + 0.5)) ** 2)))
266 if (half > 0) d += `M${cx - half * P} ${cy + r * P}h${half * 2 * P}v${P}h${-half * 2 * P}z`
267 }
268 return `<path fill="${color}" d="${d}"/>`
269}
270
271// The x a spot's way to the road runs along: straight up or down from the spot, unless a building
272// stands between it and the road (the forge, the mine, the tower all face away from it); then a lane
273// beside that building. Village units.
274export function laneOf(s: Spot): number {
275 const top = Math.min(s.y, FIRE_AT.y)
276 const bottom = Math.max(s.y, FIRE_AT.y)
277 for (const b of BUILDINGS) {
278 const x0 = b.x
279 const x1 = b.x + Math.max(...b.art.grid.map(r => r.length)) * P
280 const y0 = b.y
281 const y1 = b.y + b.art.grid.length * P
282 if (s.x > x0 - 6 && s.x < x1 + 6 && y1 > top && y0 < bottom) return x0 - 8 >= 10 ? x0 - 8 : x1 + 8
283 }
284 return s.x
285}
286
287// A dirt path from the road to a door, along the same lane walkers take: its rim, and its fill to
288// lay over the rim. `dx` is the village's left edge in the map.
289function path(fire: Spot, to: Spot, dx: number): { rim: string; fill: string } {
290 const lane = laneOf(to) + dx
291 const x = to.x + dx
292 const strip = (x1: number, y1: number, x2: number, y2: number, half: number) =>
293 `<rect x="${Math.min(x1, x2) - half}" y="${Math.min(y1, y2) - half}" width="${Math.abs(x2 - x1) + half * 2}" height="${Math.abs(y2 - y1) + half * 2}"/>`
294 const end = to.y - 6
295 const legs = (half: number) => strip(fire.x, fire.y, lane, fire.y, half) + strip(lane, fire.y, lane, end, half) + (lane !== x ? strip(lane, end, x, end, half) : '')
296 return { rim: legs(5), fill: legs(3) }
297}
298
299// A steady pseudo-random number for a seed: the same scenery every draw.
300function rand(seed: number): number {
301 const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453
302 return x - Math.floor(x)
303}
304
305// The art grows with the panel, so a wide one shows a bigger village, with forest either side.
306export const scaleFor = (w: number): number => Math.min(1.6, Math.max(1, w / 350))
307// The map's width in village units for a drawing `width` pixels wide, and where the village starts in it.
308export function mapOf(width: number): { k: number; w: number; vx: number } {
309 const k = scaleFor(width - 2 * PAD)
310 const w = Math.round((width - 2 * PAD) / k)
311 return { k, w, vx: Math.max(0, Math.round((w - VILLAGE_W) / 2)) }
312}
313
314// --- Workers. ---
315
316// How a worker looks: at work, waiting on the person, at play, asleep (done), or failed.
317export type Look = 'work' | 'wait' | 'chill' | 'sleep' | 'failed'
318
319// One worker as the scene hands it over: plain data, the same from one redraw to the next until
320// something about the worker changes. Spots are in village units; `since` is when its walk began.
321export type SceneWorker = {
322 key: string
323 name: string
324 role: string
325 look: Look
326 task: Task
327 progress: number | null
328 x: number
329 y: number
330 fx: number
331 fy: number
332 since: number
333 ms: number
334}
335
336// How many are doing what, for the line over the map: everyone, drawn or not.
337export type Count = { tasks: Partial<Record<Task, number>>; wait: number; chill: number; done: number }
338
339// Everything the colony draws: the drawing's width in pixels, the person's Still setting, the workers
340// drawn, and the count of everyone (left out, it is counted from the workers).
341export type ColonyScene = { width: number; isStill: boolean; workers: SceneWorker[]; count?: Count }
342
343// Hats as art pixels, the bottom row sitting on the head; 'h' is the hat colour, 'a' its accent.
344const HATS: Readonly<Record<string, readonly string[]>> = {
345 crown: ['a.a.a', 'hhhhh'],
346 wizard: ['...h.', '..hh.', '.hah.', 'hhhhh'],
347 tophat: ['.hhh.', '.aaa.', 'hhhhh'],
348 hardhat: ['.hhh.', 'hhahh', 'hhhhh'],
349 cap: ['.hhh.', 'hhhhhh'],
350 propeller: ['aa.aa', '..a..', '.hhh.'],
351 ranger: ['.h.h.', '.hhh.', 'hhhhh'],
352 mortarboard: ['hhhhhh', '.hhh.a'],
353 beanie: ['..a..', '.hhh.', 'hhhhh'],
354 bucket: ['.hhh.', 'hhhhh'],
355 headband: ['....h', 'hhhhh'],
356}
357// Five wide: a round head with two eyes, a shaded side, and feet; sitting, the feet tuck away.
358const BODY = ['.bbb.', 'bebeb', 'bbbbb', 'Bbbbb', '.bbb.', '.f.f.']
359const BODY_SITTING = ['.bbb.', 'bebeb', 'bbbbb', 'Bbbbb', '.bbb.']
360
361// A little something each worker wears, the same every time for the same worker: shades, round
362// specs, a scarf, a bow tie, rosy cheeks, a mustache, a backpack or a pin; or nothing. Each is a few
363// art pixels laid on the body's own rows (row 0 its head's top, the same standing or sitting) and
364// columns, kept to the body so it never meets a hat, a tool or a prop; a backpack hangs behind, on
365// the side away from the tools. `x` shifts it by whole art pixels.
366type Accessory = { grid: readonly string[]; colors: Colors; x?: number; isBehind?: boolean }
367const SCARVES = ['#d7665d', '#e8c46a', '#5fa8a0', '#9b7fc4']
368const ACCESSORIES: Readonly<Record<string, Accessory>> = {
369 shades: { grid: ['', 'sssss'], colors: { s: '#1e1d1b' } },
370 specs: { grid: ['', 'r.r.r'], colors: { r: '#f4ebd0' } },
371 ...Object.fromEntries(SCARVES.map((c, i) => [`scarf${i}`, { grid: ['', '', '', 'ccccc', 'c....'], colors: { c } }])),
372 // At the collar, on the body's last row, under the face (higher it reads as a mouth).
373 bowtie0: { grid: ['', '', '', '', '.tTt.'], colors: { t: '#d7665d', T: '#8f3a33' } },
374 bowtie1: { grid: ['', '', '', '', '.tTt.'], colors: { t: '#4f6fa8', T: '#33497a' } },
375 cheeks: { grid: ['', '', 'p...p'], colors: { p: '#e8697f' } },
376 mustache: { grid: ['', '', '.mmm.'], colors: { m: '#5a3a26' } },
377 backpack: { grid: ['', '', 'kk', 'KK', 'kk'], colors: { k: '#9a6a42', K: '#6b4029' }, x: -2, isBehind: true },
378 pin: { grid: ['', '', '', '...y'], colors: { y: '#f3d26b' } },
379}
380// About one worker in six wears nothing.
381const WARDROBE = ['', 'shades', 'specs', 'scarf', 'bowtie', 'cheeks', 'mustache', 'backpack', 'pin', '', 'scarf', 'specs'] as const
382export function accessoryOf(key: string): string {
383 const n = hashKey(`${key}|wear`)
384 const kind = WARDROBE[n % WARDROBE.length] ?? ''
385 if (kind === 'scarf') return `scarf${(n >> 5) % SCARVES.length}`
386 if (kind === 'bowtie') return `bowtie${(n >> 5) % 2}`
387 return kind
388}
389
390// What a worker holds for its task, by its right hand: a hammer, a pickaxe, a book, a lens, a scroll, a flask.
391const TOOLS: Readonly<Record<Task, { grid: readonly string[]; colors: Colors; swing: boolean }>> = {
392 develop: { grid: ['AA', 'N.', 'N.'], colors: { A: '#aab1bb', N: WOOD }, swing: true },
393 debug: { grid: ['AAA', '.N.', '.N.'], colors: { A: '#aab1bb', N: WOOD }, swing: true },
394 research: { grid: ['kK', 'kK'], colors: { k: '#f4ebd0', K: '#4f6fa8' }, swing: false },
395 review: { grid: ['AA.', 'AA.', '..N'], colors: { A: '#bfe3f2', N: WOOD }, swing: false },
396 plan: { grid: ['k', 'k', 'k'], colors: { k: '#f4ebd0' }, swing: false },
397 test: { grid: ['g', 'L'], colors: { g: '#cfe9f2', L: '#5fb57a' }, swing: false },
398}
399
400const hsl = (hue: number, s: number, l: number): string => `hsl(${hue} ${s}% ${l}%)`
401
402// Workers are drawn a touch larger than the scenery's art pixels, so they read beside the buildings.
403const WORKER_SCALE = 1.15
404// Captains all wear the crown; their bodies take one of these hues so a plaza of them reads as people.
405const CAPTAIN_HUES = [14, 32, 152, 188, 214, 268, 304, 336]
406const CROWN = '#d9a43a'
407
408// What a worker with nothing to do gets up to, the same one every time for the same worker.
409// Round the fire: a sip, a stroll, a juggled ball, a dance, a tune on the lute, a book, a kite, the
410// birds fed. At the venues: fishing at the pond, watering the garden, petting the cat.
411export const PLAYS = ['sip', 'stroll', 'juggle', 'dance', 'fish', 'water', 'pet', 'lute', 'read', 'kite', 'birds'] as const
412export type Play = (typeof PLAYS)[number]
413// What one does at a fire seat when its venue is full: never a stroll, so only strollers stroll.
414const SEAT_PLAYS: readonly Play[] = ['sip', 'juggle', 'dance', 'lute', 'read', 'kite', 'birds']
415// And at a seat with no room for a kite or the birds: something that keeps to its own spot.
416const GROUNDED: readonly Play[] = ['sip', 'juggle', 'dance', 'lute', 'read']
417export const hashKey = (key: string): number => [...key].reduce((n, ch) => (n * 31 + ch.charCodeAt(0)) >>> 0, 11)
418export const playOf = (key: string): Play => PLAYS[hashKey(key) % PLAYS.length] ?? 'sip'
419// What a worker at play does standing at `at`, and whether it faces left: its own pastime, unless
420// that belongs at a venue and it isn't standing there (the venue was full); then one for the fire.
421export function pastimeAt(key: string, at: Spot): { play: Play; flip: boolean } {
422 const own = playOf(key)
423 const venue = venueOf(own)
424 const spot = venue?.spots.find(s => s.x === at.x && s.y === at.y)
425 if (spot) return { play: own, flip: spot.flip === true }
426 const play = venue ? (SEAT_PLAYS[(hashKey(key) >> 4) % SEAT_PLAYS.length] ?? 'sip') : own
427 if ((play === 'kite' || play === 'birds') && !hasRoomFor(play, at)) return { play: GROUNDED[(hashKey(key) >> 6) % GROUNDED.length] ?? 'sip', flip: false }
428 return { play, flip: false }
429}
430
431// The ground a kite (up and to the right, on its string) or the birds (close about the feet)
432// take from a worker standing at `at`, in village units.
433export function reachOf(play: 'kite' | 'birds', at: Spot): Box {
434 return play === 'kite' ? { x: at.x + 2, y: at.y - 37, w: 25, h: 23 } : { x: at.x - 12, y: at.y - 9, w: 24, h: 13 }
435}
436// Whether that ground keeps clear of every building, sign, tent, venue and other seat, and a kite's of the fire
437// and the spots where a crowd stands at a door too (it must never hide the one waiting on the
438// person). Decided by seat, so it stays the same.
439const roomy = new Map<string, boolean>()
440export function hasRoomFor(play: 'kite' | 'birds', at: Spot): boolean {
441 const id = `${play}|${at.x},${at.y}`
442 const known = roomy.get(id)
443 if (known !== undefined) return known
444 const box = reachOf(play, at)
445 // A door's crowd: its first two lines, side by side.
446 const crowds = Object.values(SPOTS).flatMap(d => [0, 22].flatMap(dy => Array.from({ length: CROWD }, (_, i) => ({ x: d.x + nudge(i) - 6, y: d.y + dy - 24, w: 12, h: 24 }))))
447 const isInside = box.x >= 0 && box.y >= 0 && box.x + box.w <= VILLAGE_W
448 const walls = BUILDINGS.map(b => ({ x: b.x, y: b.y, w: Math.max(...b.art.grid.map(r => r.length)) * P, h: b.art.grid.length * P }))
449 const signs = BUILDINGS.map(b => ({ x: b.x + b.sign.x, y: b.y + b.sign.y - 6, w: BOARD_SIZE, h: BOARD_SIZE + (b.wall === null ? 16 : 6) }))
450 // Whoever sits at the other seats, the same way.
451 const seated = SEAT_LIST.filter(s => s.x !== at.x || s.y !== at.y).map(s => ({ x: s.x - 6, y: s.y - 24, w: 12, h: 24 }))
452 const isRoomy = isInside && ![...walls, ...signs, ...TENTS, ...VENUES.map(v => v.box), ...seated, ...(play === 'kite' ? [...crowds, { x: FIRE_AT.x - 10, y: FIRE_AT.y - 10, w: 20, h: 16 }] : [])].some(o => overlaps(box, o))
453 roomy.set(id, isRoomy)
454 return isRoomy
455}
456// Each loop's length, so its phase can follow the clock: a fresh drawing carries on mid-move.
457export const LOOP_MS = {
458 work: 1_600, swing: 1_600, dance: 2_400, juggle: 2_800, sip: 5_000, bang: 1_800, zz: 3_200,
459 fish: 6_400, drip: 1_200, pet: 1_600, heart: 5_600, strum: 1_600, note: 3_600, read: 7_000, kite: 9_000, birds: 3_600, tail: 5_000,
460} as const
461// A worker's loops all share one delay, `--d`, set once on the worker: taken over a span that every
462// loop's length divides, so each loop is as far in as the clock says. `loopDelay` is one loop's share.
463const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b))
464export const CLOCK_SPAN = Object.values(LOOP_MS).reduce((a: number, b: number) => (a * b) / gcd(a, b), 1)
465export const workerDelay = (now: number, key: string): number => (now + hashKey(key) * 7_919) % CLOCK_SPAN
466export const loopDelay = (loop: string, now: number, key: string): number => workerDelay(now, key) % ((LOOP_MS as Readonly<Record<string, number>>)[loop] ?? 1_000)
467
468// --- Motion the scene and the drawing share: the scene asks where a worker is now, so a worker
469// sent somewhere new sets off from exactly where the drawing has it. ---
470
471// A CSS cubic-bezier as a function of time.
472function cubicBezier(x1: number, y1: number, x2: number, y2: number): (t: number) => number {
473 const at = (a: number, b: number, s: number) => 3 * a * s * (1 - s) ** 2 + 3 * b * s * s * (1 - s) + s ** 3
474 return t => {
475 if (t <= 0) return 0
476 if (t >= 1) return 1
477 let lo = 0
478 let hi = 1
479 let s = t
480 for (let i = 0; i < 28; i += 1) {
481 s = (lo + hi) / 2
482 if (at(x1, x2, s) < t) lo = s
483 else hi = s
484 }
485 return at(y1, y2, s)
486 }
487}
488
489type Ease = { css: string; at: (t: number) => number }
490const ease = (x1: number, y1: number, x2: number, y2: number): Ease => ({ css: `cubic-bezier(${x1},${y1},${x2},${y2})`, at: cubicBezier(x1, y1, x2, y2) })
491const STEADY_PACE: Ease = { css: 'linear', at: t => t }
492const EASE_IN_OUT = ease(0.42, 0, 0.58, 1)
493// A walk sets off and comes to rest gently: eased in on its first leg and out on its last, steady
494// between. An eased leg gets this much more time, so it meets the steady pace at the join.
495const WALK_ONE = ease(0.35, 0, 0.65, 1)
496const WALK_START = ease(0.45, 0, 0.8, 0.75)
497const WALK_END = ease(0.2, 0.25, 0.55, 1)
498const EASED_LEG = 1.35
499
500// Walking pace, in map units a second, and how short or long a walk may be.
501const WALK_SPEED = 30
502const WALK_MS = { min: 800, max: 9_000 }
503
504// One leg of a walk, its start and end as fractions of the walk's time.
505type Leg = { from: Spot; to: Spot; start: number; end: number; ease: Ease }
506export type Route = { legs: Leg[]; ms: number }
507
508// The way from one spot to another: along the dirt roads, which run from each door down to the road
509// across the plaza (FIRE_AT.y) and along it. A step to a spot close by goes straight.
510export function routeOf(from: Spot, to: Spot): Route {
511 const isNear = Math.abs(from.y - to.y) < 12 && Math.abs(from.x - to.x) < 60
512 // Out along its lane to the road, along the road, and in along the other's lane.
513 const out = laneOf(from)
514 const into = laneOf(to)
515 const points = isNear
516 ? [from, to]
517 : [from, { x: out, y: from.y }, { x: out, y: FIRE_AT.y }, { x: into, y: FIRE_AT.y }, { x: into, y: to.y }, to]
518 const stops = points.reduce<Spot[]>((list, p) => {
519 const last = list[list.length - 1]
520 return last && Math.hypot(p.x - last.x, p.y - last.y) < 0.5 ? list : [...list, p]
521 }, [])
522 if (stops.length < 2) stops.push(to)
523 const lengths = stops.slice(1).map((p, i) => Math.hypot(p.x - (stops[i]?.x ?? 0), p.y - (stops[i]?.y ?? 0)))
524 const n = lengths.length
525 const weights = lengths.map((len, i) => (n > 1 && (i === 0 || i === n - 1) ? len * EASED_LEG : len))
526 const total = weights.reduce((a, b) => a + b, 0)
527 const ms = Math.round(Math.min(WALK_MS.max, Math.max(WALK_MS.min, (total / WALK_SPEED) * 1_000)))
528 let start = 0
529 const legs = weights.map((weight, i): Leg => {
530 const end = i === n - 1 ? 1 : total > 0 ? start + weight / total : (i + 1) / n
531 const leg = { from: stops[i] ?? from, to: stops[i + 1] ?? to, start, end, ease: n === 1 ? WALK_ONE : i === 0 ? WALK_START : i === n - 1 ? WALK_END : STEADY_PACE }
532 start = end
533 return leg
534 })
535 return { legs, ms }
536}
537
538// Where a walk has got to, `elapsed` milliseconds in.
539export function pointOn(route: Route, elapsed: number): Spot {
540 const f = Math.min(1, Math.max(0, elapsed / route.ms))
541 const leg = route.legs.find(l => f <= l.end) ?? route.legs[route.legs.length - 1]
542 if (!leg) return { x: 0, y: 0 }
543 const local = leg.ease.at(leg.end > leg.start ? (f - leg.start) / (leg.end - leg.start) : 1)
544 return { x: leg.from.x + (leg.to.x - leg.from.x) * local, y: leg.from.y + (leg.to.y - leg.from.y) * local }
545}
546
547// A walk's keyframes, `dx` added to every x (the village's left edge in the map).
548function walkKeyframes(id: string, route: Route, dx: number): string {
549 const at = (p: Spot) => `transform:translate(${+(p.x + dx).toFixed(1)}px,${+p.y.toFixed(1)}px)`
550 // A steady leg takes the walk's own (linear) timing, so only the eased ones name theirs.
551 const frames = route.legs.map(l => `${+(l.start * 100).toFixed(2)}%{${at(l.from)}${l.ease === STEADY_PACE ? '' : `;animation-timing-function:${l.ease.css}`}}`)
552 const last = route.legs[route.legs.length - 1]
553 return `@keyframes ${id}{${frames.join('')}100%{${last ? at(last.to) : ''}}}`
554}
555
556// A stroller's little loop round its seat, in map units: it stands a while at each corner, then
557// ambles on. The keyframes and the scene's sums both come from here.
558// A gardener paces its row the same way: waters a while, steps along, waters, steps back.
559type Wander = 'a' | 'b' | 'pace' | 'pace-l'
560const WANDERS: Readonly<Record<Wander, { stops: readonly Spot[]; ms: number; hold: number; step: number }>> = {
561 a: { stops: [{ x: 0, y: 0 }, { x: 18, y: -5 }, { x: 4, y: -14 }, { x: 0, y: 0 }], ms: 22_000, hold: 0.14, step: 0.3 },
562 b: { stops: [{ x: 0, y: 0 }, { x: -16, y: -6 }, { x: -6, y: 6 }, { x: 0, y: 0 }], ms: 22_000, hold: 0.14, step: 0.3 },
563 pace: { stops: [{ x: 0, y: 0 }, { x: 6, y: 0 }, { x: 0, y: 0 }], ms: 18_000, hold: 0.3, step: 0.5 },
564 'pace-l': { stops: [{ x: 0, y: 0 }, { x: -6, y: 0 }, { x: 0, y: 0 }], ms: 18_000, hold: 0.3, step: 0.5 },
565}
566const strollOf = (key: string): 'a' | 'b' => ((hashKey(key) >> 2) % 2 === 0 ? 'a' : 'b')
567// Which loop a worker at play wanders, standing at `at` (its seat or venue spot), if any.
568const wanderOf = (key: string, at?: Spot): Wander | null => {
569 const play = playOf(key)
570 if (play === 'stroll') return strollOf(key)
571 if (play !== 'water' || !at) return null
572 const here = pastimeAt(key, at)
573 return here.play !== 'water' ? null : here.flip ? 'pace-l' : 'pace'
574}
575
576// How far a stroller (or a gardener, at `at`) has wandered from its spot, `ms` after it got there;
577// 0 for anyone else.
578export function strollAt(key: string, ms: number, at?: Spot): Spot {
579 const name = wanderOf(key, at)
580 if (!name || ms <= 0) return { x: 0, y: 0 }
581 const { stops, ms: loop, hold, step } = WANDERS[name]
582 const t = (ms % loop) / loop
583 for (let i = 0; i < stops.length - 1; i += 1) {
584 const here = stops[i] ?? { x: 0, y: 0 }
585 const next = stops[i + 1] ?? here
586 const moveFrom = i * step + hold
587 if (t <= moveFrom) return here
588 if (t <= (i + 1) * step) {
589 const k = EASE_IN_OUT.at((t - moveFrom) / (step - hold))
590 return { x: here.x + (next.x - here.x) * k, y: here.y + (next.y - here.y) * k }
591 }
592 }
593 return { x: 0, y: 0 }
594}
595
596export function strollKeyframes(name: Wander): string {
597 const { stops, hold, step } = WANDERS[name]
598 const at = (p: Spot | undefined) => `transform:translate(${p?.x ?? 0}px,${p?.y ?? 0}px)`
599 // Each corner held from i*step for `hold`, the last to the loop's end: the same numbers
600 // strollAt walks through.
601 const pct = (f: number) => `${Math.round(f * 100)}%`
602 const frames = stops.map((p, i) => `${pct(i * step)},${pct(i === stops.length - 1 ? 1 : i * step + hold)}{${at(p)}}`)
603 return `@keyframes stroll-${name}{${frames.join('')}}`
604}
605
606// Shapes many workers share, each drawn once in <defs> and placed with <use>: a body per role and
607// pose, the tools, the play props, the bubbles. A drawing in the page is held to 100,000 characters.
608class Defs {
609 private readonly ids = new Map<string, string>()
610 readonly svg: string[] = []
611 of(key: string, draw: () => string): string {
612 const known = this.ids.get(key)
613 if (known) return known
614 const id = `d${this.ids.size}`
615 this.ids.set(key, id)
616 this.svg.push(`<g id="${id}">${draw()}</g>`)
617 return id
618 }
619}
620
621const use = (id: string, x = 0, y = 0): string => `<use href="#${id}"${x || y ? ` x="${x}" y="${y}"` : ''}/>`
622
623// What a worker at play holds, drawn from its right hand at (hx, hy), mirrored when it faces left.
624// The feet are at y 0. Each little motion follows the clock, so a redraw carries it on.
625function propsOf(play: Play, flip: boolean, hx: number, hy: number, skin: string, defs: Defs): string {
626 const loop = (cls: string, inner: string) => `<g class="${cls} l">${inner}</g>`
627 const at = (inner: string) => `<g transform="${flip ? 'scale(-1 1) ' : ''}translate(${hx} ${hy})">${inner}</g>`
628 const feet = -hy
629 switch (play) {
630 case 'fish': {
631 // A rod over the water, its line down to a bobber that twitches, then dips with a ripple.
632 const rod = defs.of('rod', () => sprite(['.....N', '....N.', '...N..', '..N...', '.N....', 'N.....'], { N: '#8b5a3c' }, 0, -9) + `<path d="M11.3 -8V${feet - 4}" stroke="#f4ebd0" stroke-width=".5" stroke-opacity=".8"/>`)
633 const bobber = defs.of('bobber', () => sprite(['r', 'w'], { r: '#e2574c', w: '#fbf3e0' }, 0, 0))
634 const ripple = defs.of('ripple', () => `<path fill="#cfe9f2" d="M0 0h2v1h-2zM8 0h2v1h-2z"/>`)
635 return at(use(rod) + loop('fish', use(bobber, 10.3, feet - 4)) + loop('ripple', use(ripple, 6.3, feet - 0.5)))
636 }
637 case 'water': {
638 // A watering can, dripping on the row.
639 const can = defs.of('can', () => sprite(['.h..', 'cccs', 'ccc.'], { h: '#4a5560', c: '#7fa0b8', s: '#9fb9cc' }, 0, -1))
640 const drops = defs.of('drops', () => `<path fill="#9fd3ea" d="M8 3h1v1.5h-1zM8.5 6h1v1.5h-1z"/>`)
641 return at(use(can) + loop('drip', use(drops)))
642 }
643 case 'pet':
644 // A hand stroking the cat beside it.
645 return at(loop('pet', `<rect x="0" y="1" width="3" height="2" fill="${skin}" stroke="${OUT}" stroke-width=".6"/>`))
646 case 'lute': {
647 const lute = defs.of('lute', () => sprite(['....n', '...n.', 'LLn..', 'LLL..', '.L...'], { L: '#c98a4a', n: '#6b4029' }, -7, -2))
648 return at(loop('strum', use(lute)))
649 }
650 case 'read': {
651 // An open book; now and then a page turns.
652 const book = defs.of('book', () => sprite(['ppKpp', 'ppKpp', 'BBBBB'], { p: '#f4ebd0', K: '#c9b998', B: '#8b5a3c' }, -10, 0))
653 const turning = defs.of('book-turn', () => sprite(['...p.', 'ppKp.', 'ppKpp', 'BBBBB'], { p: '#f4ebd0', K: '#c9b998', B: '#8b5a3c' }, -10, -2))
654 return at(loop('read-a', use(book)) + loop('read-b', use(turning)))
655 }
656 case 'kite': {
657 // A kite on a string, drifting from side to side in three steps, as pixel art moves.
658 const kite = defs.of('kite', () => sprite(['..k..', '.kkK.', 'kkKKK', '.KKK.', '..K..', '...r.', '..r..'], { k: '#f2a33a', K: '#e2574c', r: '#fbf3e0' }, 3, -21))
659 const frame = (dx: number) => defs.of(`kite${dx}`, () => `<path d="M0.5 0.5L${8 + dx} -11" stroke="#f4ebd0" stroke-width=".5" stroke-opacity=".8"/>${use(kite, dx)}`)
660 return at(loop('kite-a', use(frame(-2))) + loop('kite-b', use(frame(0))) + loop('kite-c', use(frame(2))))
661 }
662 case 'birds': {
663 // Two little birds hop about the feet for crumbs, each to its own rhythm, keeping close so they
664 // stay clear of the next seat.
665 const bird = defs.of('bird', () => sprite(['.hy', 'bb.'], { b: '#c49a6c', h: '#8b5a3c', y: '#f3c14a' }, 0, 0) + `<path fill="#e9d6a8" d="M6 3h1v1h-1z"/>`)
666 return `<g transform="translate(-8 -3)">${loop('bird-a', use(bird))}</g><g transform="translate(8 -3) scale(-1 1)">${loop('bird-b', use(bird))}</g>`
667 }
668 default:
669 return ''
670 }
671}
672
673// Where each working or waiting worker's name goes, in light letters with a dark edge and no box
674// behind (`.nm`): there to read, not to look at. Workers at play or asleep go unnamed: the line over
675// the map counts them, and the list above names everyone.
676//
677// A name sits right by its own worker or not at all: just under its feet, or else straight over its
678// head (over its bar or "!" if one stands there), centred or slid along while it still reaches over the body. It must
679// keep clear of every sign, every "!" and bar, every other worker and every name already placed; in a
680// crowd where neither spot is clear the name is left out, since a name off to one side reads as a
681// neighbour's. Anyone who needs the person is named first, then those at work, each top to bottom
682// and left to right, so the same scene always names the same workers in the same places. Village
683// units; `x`/`y` are the text's middle and baseline from the worker's feet, `box` where it lies.
684export type NamePlace = { x: number; y: number; box: Box }
685export const nameSizeOf = (width: number): number => Math.round(Math.max(7, 9 / mapOf(width).k) * 10) / 10
686// How far above its feet a worker's head is, and what stands over it (its bar or "!"), if anything.
687export function headOf(w: SceneWorker): { head: number; mark: Box | null } {
688 const hat = (HATS[roleOf(w.role).hat] ?? HATS.beanie ?? []).length
689 const head = -(hat + BODY.length) * P * WORKER_SCALE
690 if (w.look === 'wait') return { head, mark: { x: w.x - 7, y: w.y + head - 19, w: 14, h: 18 } }
691 if (w.look === 'work' && w.progress !== null) return { head, mark: { x: w.x - 11, y: w.y + head - 8, w: 22, h: 7 } }
692 return { head, mark: null }
693}
694export function placeNames(workers: readonly SceneWorker[], width: number): Map<string, NamePlace> {
695 const { w: mapW, vx } = mapOf(width)
696 const size = nameSizeOf(width)
697 const named = workers.filter(w => w.look !== 'chill' && w.look !== 'sleep')
698 const bodies = workers.map(w => ({ key: w.key, box: { x: w.x - 6, y: w.y + headOf(w).head, w: 12, h: -headOf(w).head } }))
699 const marks = named.flatMap(w => {
700 const mark = headOf(w).mark
701 return mark ? [{ key: w.key, box: mark }] : []
702 })
703 const signs = BUILDINGS.map(b => ({ x: b.x + b.sign.x, y: b.y + b.sign.y - 6, w: BOARD_SIZE, h: BOARD_SIZE + (b.wall === null ? 16 : 6) }))
704 const placed = new Map<string, NamePlace>()
705 const rank = (w: SceneWorker): number => (w.look === 'wait' ? 0 : w.look === 'work' ? 1 : 2)
706 const order = [...named].sort((a, b) => rank(a) - rank(b) || a.y - b.y || a.x - b.x || (a.key < b.key ? -1 : 1))
707 const r1 = (v: number) => Math.round(v * 10) / 10
708 const ascent = size * 0.8
709 const tall = size * 1.05
710 for (const w of order) {
711 const half = measure(w.name, size, true) / 2 + 2
712 const { head, mark } = headOf(w)
713 const top = mark ? mark.y - w.y : head
714 const below = r1(3 + ascent)
715 const above = r1(top - 1 - (tall - ascent))
716 const slide = r1(Math.max(0, half - 4))
717 // Over the head only where nothing stands there: above a bar or a "!" a name floats up over the
718 // roofs, too far off to read as this worker's. With no clear spot the name is left out.
719 const spots = [
720 { x: 0, y: below }, { x: slide, y: below }, { x: -slide, y: below },
721 ...(mark === null ? [{ x: 0, y: above }, { x: slide, y: above }, { x: -slide, y: above }] : []),
722 ]
723 for (const spot of spots) {
724 // Kept inside the map, as far as the name is long.
725 const cx = Math.min(mapW - vx - 3 - half, Math.max(3 - vx + half, w.x + spot.x)) - w.x
726 const box = { x: w.x + cx - half, y: w.y + spot.y - ascent, w: half * 2, h: tall }
727 if (box.y < 1 || box.y + box.h > MAP_TALL - 1) continue
728 const isClear =
729 !signs.some(o => overlaps(box, o)) &&
730 !marks.some(o => overlaps(box, o.box)) &&
731 ![...placed.values()].some(o => overlaps(box, o.box)) &&
732 !bodies.some(o => o.key !== w.key && overlaps(box, o.box))
733 if (isClear) {
734 placed.set(w.key, { x: r1(cx), y: spot.y, box })
735 break
736 }
737 }
738 }
739 return placed
740}
741
742function tagSvg(w: SceneWorker, place: NamePlace | undefined): string {
743 if (!place) return ''
744 return `<text x="${place.x}" y="${place.y}" class="nm">${escape(w.name)}</text>`
745}
746
747// A worker, and its name apart: names go in a layer over every worker so none hides behind
748// another, each wrapped in the same walk and stroll so it keeps to its worker. `walks` gathers
749// each walk's keyframes for the drawing's style.
750function workerSvg(w: SceneWorker, vx: number, now: number, defs: Defs, name: NamePlace | undefined, walks: string[]): { body: string; tag: string } {
751 const role = roleOf(w.role)
752 const isCaptain = w.role === 'lead'
753 const hue = isCaptain ? (CAPTAIN_HUES[hashKey(w.key) % CAPTAIN_HUES.length] ?? role.hue) : role.hue
754 const isAsleep = w.look === 'sleep'
755 const pastime = w.look === 'chill' ? pastimeAt(w.key, { x: w.x, y: w.y }) : null
756 const play: Play | null = pastime?.play ?? null
757 const isSitting = play === 'sip' || play === 'read' || play === 'pet'
758 const hatGrid = HATS[role.hat] ?? HATS.beanie ?? []
759 const width = Math.max(5, ...hatGrid.map(r => r.length))
760 const left = -(width / 2) * P
761 const S = WORKER_SCALE
762 const skin = w.look === 'failed' ? '#d7665d' : isAsleep ? '#a39e95' : hsl(hue, 58, 70)
763 const bodyOf = (sits: boolean): { id: string; top: number } => {
764 const grid = [...hatGrid, ...(sits ? BODY_SITTING : BODY)].map(r => r.padEnd(width, '.'))
765 const pose = w.look === 'failed' ? 'failed' : isAsleep ? 'asleep' : sits ? 'sitting' : 'up'
766 const id = defs.of(`body|${role.hat}|${hue}|${isCaptain}|${pose}`, () => {
767 const hat = isCaptain ? CROWN : hsl(hue, 52, isAsleep ? 34 : 40)
768 return sprite(grid, { h: hat, a: '#f4ebd0', b: skin, B: hsl(hue, 45, isAsleep ? 50 : 58), e: OUT, f: '#3b2f2a' }, left, -grid.length * P)
769 })
770 return { id, top: -grid.length * P }
771 }
772 const body = bodyOf(isSitting)
773 const top = body.top
774 // What it wears, laid over (or, a backpack, behind) a body whose top is at `y`.
775 const wear = accessoryOf(w.key)
776 // Rosy cheeks vanish on a pink or red skin, so they are left off it.
777 const isPinkish = hue >= 290 || hue <= 20 || w.look === 'failed'
778 const kit = wear === 'cheeks' && isPinkish ? undefined : ACCESSORIES[wear]
779 const dressed = (id: string, y: number): string => {
780 if (!kit) return use(id)
781 // Asleep it goes grey with the body.
782 const colors = isAsleep ? Object.fromEntries(Object.keys(kit.colors).map(k => [k, k === k.toUpperCase() ? '#7d7a74' : '#9a958d'])) : kit.colors
783 const on = use(defs.of(`wear|${wear}|${isAsleep}`, () => sprite(kit.grid, colors, 0, 0, kit.isBehind === true)), left + (kit.x ?? 0) * P, y + hatGrid.length * P)
784 return kit.isBehind ? on + use(id) : use(id) + on
785 }
786 const tool = w.look === 'work' ? TOOLS[w.task] : undefined
787 const elapsed = Math.max(0, now - w.since)
788 const isWalking = elapsed < w.ms
789 // On the way to play, a worker walks upright with empty hands: its pose and props wait for it to
790 // arrive, timed here, since no redraw comes then. With nothing moving, it shows as arrived.
791 const arrival = `style="animation-delay:${w.since + w.ms - now}ms"`
792 const arrive = (inner: string) => (isWalking && inner ? `<g class="ar" ${arrival}>${inner}</g>` : inner)
793 // The body's own loop: a slow bob at work, a sway when dancing; on the way, a step every half second.
794 const loop = w.look === 'work' ? 'work' : play === 'dance' ? 'dance' : ''
795 const parts = [`<ellipse cx="0" cy="1" rx="7" ry="2" class="sh"/>`]
796 parts.push(`<g transform="scale(${S})">`)
797 if (isWalking) parts.push(`<g class="step" style="animation-iteration-count:${(w.ms / 500).toFixed(3)};animation-delay:-${elapsed}ms">`)
798 parts.push(`<g${loop ? ` class="${loop} l"` : ''}>`)
799 const held: string[] = []
800 if (play === 'sip') held.push(`<g class="sip l">${use(defs.of('mug', () => sprite(['m', 'm'], { m: '#9a6a42' }, 0, 0)), left + 5 * P, top + (hatGrid.length + 2) * P)}</g>`)
801 if (play === 'juggle') held.push(`<g class="juggle l">${use(defs.of('ball', () => sprite(['rr', 'rr'], { r: '#e2574c' }, 0, 0)), left + 6 * P, top - 2)}</g>`)
802 if (play && pastime) held.push(propsOf(play, pastime.flip, left + 5 * P, top + (hatGrid.length + 2) * P, skin, defs))
803 if (isWalking && isSitting) {
804 const up = bodyOf(false)
805 parts.push(`<g class="lv" ${arrival}>${dressed(up.id, up.top)}</g>`, arrive(dressed(body.id, top) + held.join('')))
806 } else parts.push(dressed(body.id, top), arrive(held.join('')))
807 if (tool) parts.push(`<g${tool.swing ? ' class="swing l"' : ''}>${use(defs.of(`tool|${w.task}`, () => sprite(tool.grid, tool.colors, 0, 0)), left + 5 * P + 1, top + (hatGrid.length + 1) * P)}</g>`)
808 parts.push(isWalking ? '</g></g></g>' : '</g></g>')
809 const head = top * S
810 // Over its head: progress as a unit's bar, a "!" when it needs the person, z's asleep.
811 if (w.look === 'wait') {
812 parts.push(`<g class="bang l">${use(defs.of('bang', () => sprite(['.www.', 'wwyww', 'wwyww', 'wwwww', 'wwyww', '.www.', '..w..'], { w: '#fbf3e0', y: '#d99a1e' }, 0, 0)), -5, head - 17)}</g>`)
813 } else if (isAsleep) {
814 parts.push(`<g class="zz l">${use(defs.of('zz', () => sprite(['zzz', '.z.', 'zzz'], { z: '#fbf3e0' }, 0, 0, false)), 8, head - 4)}</g>`)
815 } else if (play === 'pet') {
816 // Now and then a little heart rises over the cat, centred on it.
817 const cat = venueOf('pet')?.at.x ?? w.x
818 parts.push(arrive(`<g class="heart l">${use(defs.of('heart', () => sprite(['h.h', 'hhh', '.h.'], { h: '#f28aa0' }, 0, 0)), cat - w.x - 3, head - 2)}</g>`))
819 } else if (play === 'lute') {
820 parts.push(arrive(`<g class="note l">${use(defs.of('note', () => sprite(['..n', '..n', 'nnn', 'nn.'], { n: '#f7d154' }, 0, 0)), 7, head - 6)}</g>`))
821 } else if (w.progress !== null && w.look === 'work') {
822 const bw = 18
823 const fill = Math.max(1, Math.round(bw * w.progress))
824 parts.push(`<path class="pb" d="M${-bw / 2 - 1} ${head - 7}h${bw + 2}v5h${-bw - 2}z"/><path fill="${hsl(hue, 70, 62)}" d="M${-bw / 2} ${head - 6}h${fill}v3h${-fill}z"/>`)
825 }
826 const to = { x: vx + w.x, y: w.y }
827 const tag = tagSvg(w, name)
828 // A stroller wanders its little loop, counted from when it sat down, so it sets off from its seat
829 // and the scene knows where it is when it leaves (a positive delay holds it still while it walks).
830 // A gardener paces its row the same way.
831 const wander = play === null ? null : wanderOf(w.key, { x: w.x, y: w.y })
832 const stroll = (inner: string) => (wander ? `<g class="stroll-${wander}" style="animation-delay:${w.since + w.ms - now}ms">${inner}</g>` : inner)
833 // Its walk along the roads, carried on from wherever it has got to by now.
834 // Every loop in it takes the worker's one clock delay, `--d`; its name has no loops.
835 const clock = `--d:-${workerDelay(now, w.key)}ms`
836 let place = (inner: string, isTag = false) => `<g transform="translate(${to.x} ${to.y})"${isTag ? '' : ` style="${clock}"`}>${stroll(inner)}</g>`
837 if (isWalking) {
838 const id = `w${walks.length}`
839 walks.push(walkKeyframes(id, routeOf({ x: w.fx, y: w.fy }, { x: w.x, y: w.y }), vx))
840 place = (inner, isTag = false) => `<g class="walk" style="transform:translate(${to.x}px,${to.y}px);animation-name:${id};animation-duration:${w.ms}ms;animation-delay:-${elapsed}ms${isTag ? '' : `;${clock}`}">${stroll(inner)}</g>`
841 }
842 // A walker's name waits for it to arrive: it is placed for where the worker is going, so on the
843 // way it could run into a name standing there.
844 return { body: place(parts.join('')), tag: tag ? place(arrive(tag), true) : '' }
845}
846
847// The person's Still setting: everything holds still, whatever the OS asks. Reduced motion does the
848// same: everyone is already where they are going, has arrived, and holds still.
849const STILL = '*{animation:none!important}.smoke rect{opacity:.5}'
850
851// The motion every drawing carries.
852const BASE_MOTION = [
853 // Names, shadows and unit bars, set once here rather than on every worker.
854 `.nm{font-weight:650;text-anchor:middle;fill:#fbf3e0;stroke:${OUT};stroke-width:2.2px;stroke-linejoin:round;paint-order:stroke}.sh{fill:#000;fill-opacity:.22}.pb{fill:${OUT}}`,
855 // Walks follow their own keyframes along the roads (their length sets their time), with a small
856 // step on the way; work is a slow bob, and a tool is raised slowly and brought down quickly.
857 '.walk{animation-fill-mode:both;animation-timing-function:linear}',
858 '.step{animation:step .5s}@keyframes step{0%{transform:translateY(0);animation-timing-function:steps(1,end)}50%{transform:translateY(-1px);animation-timing-function:steps(1,end)}100%{transform:translateY(0)}}',
859 '.work{animation:work 1.6s ease-in-out infinite}@keyframes work{0%,100%{transform:translateY(0)}50%{transform:translateY(-1.5px)}}',
860 '.swing{transform-box:fill-box;transform-origin:0 100%;animation:swing 1.6s infinite}',
861 '@keyframes swing{0%{transform:rotate(-6deg);animation-timing-function:cubic-bezier(.42,0,.58,1)}60%{transform:rotate(-30deg);animation-timing-function:cubic-bezier(.5,0,1,1)}70%,80%{transform:rotate(12deg);animation-timing-function:cubic-bezier(0,0,.58,1)}100%{transform:rotate(-6deg)}}',
862 '.smoke rect{animation:smoke 3.2s ease-out infinite;opacity:0}',
863 '@keyframes smoke{0%{transform:translateY(0);opacity:.75}100%{transform:translateY(-14px);opacity:0}}',
864 // The fire swaps between its two frames, as pixel art does, rather than fading one into the other.
865 '.fire-b{animation:flick .5s steps(1,end) infinite}@keyframes flick{0%{opacity:0}50%{opacity:1}100%{opacity:0}}',
866 '.bang{animation:bang 1.8s ease-in-out infinite}@keyframes bang{0%,100%{opacity:.6}50%{opacity:1}}',
867 '.zz{animation:zz 3.2s ease-out infinite}@keyframes zz{0%{transform:translateY(0);opacity:.9}100%{transform:translateY(-6px);opacity:0}}',
868 // The village cat flicks its tail now and then; the frame shown only then is hidden while still.
869 `.tail-b{opacity:0}.tail-a,.tail-b{animation:${LOOP_MS.tail}ms steps(1,end) infinite}.tail-a{animation-name:tail-a}.tail-b{animation-name:tail-b}@keyframes tail-a{0%{opacity:1}80%{opacity:0}88%{opacity:1}}@keyframes tail-b{0%{opacity:0}80%{opacity:1}88%{opacity:0}}`,
870 // On a dark page the village dims a little, like evening, so it doesn't glare.
871 '@media (prefers-color-scheme:dark){.village{filter:brightness(.86) saturate(.92)}}',
872 `@media (prefers-reduced-motion:reduce){${STILL}}`,
873].join('')
874
875// Each pastime's motion, carried only by a drawing with someone at it: slow, and in whole steps
876// where it can be. Frames shown only part of the time are hidden while nothing moves.
877const strollCss = (name: Wander) => `.stroll-${name}{animation:stroll-${name} ${WANDERS[name].ms}ms ease-in-out infinite}${strollKeyframes(name)}`
878const PLAY_MOTION: Readonly<Record<Play, string>> = {
879 // A stroll round the plaza with pauses, a sway, a juggled ball, a sip.
880 stroll: strollCss('a') + strollCss('b'),
881 dance: '.dance{transform-box:fill-box;transform-origin:50% 100%;animation:dance 2.4s ease-in-out infinite}@keyframes dance{0%,100%{transform:rotate(-6deg)}25%{transform:translateY(-1.5px)}50%{transform:rotate(6deg)}75%{transform:translateY(-1.5px)}}',
882 juggle: '.juggle{animation:juggle 1.4s cubic-bezier(.2,.6,.4,1) infinite alternate}@keyframes juggle{from{transform:translateY(0)}to{transform:translateY(-9px)}}',
883 sip: '.sip{animation:sip 5s ease-in-out infinite}@keyframes sip{0%,68%,100%{transform:translate(0,0)}78%,90%{transform:translate(-2px,-3px)}}',
884 // A bobber that twitches, then dips with a ripple.
885 fish: `.ripple{opacity:0}.fish{animation:fish ${LOOP_MS.fish}ms steps(1,end) infinite}@keyframes fish{0%{transform:translateY(0)}30%{transform:translateY(.6px)}34%{transform:translateY(0)}62%{transform:translateY(.6px)}66%{transform:translateY(0)}72%{transform:translateY(1.6px)}84%{transform:translateY(0)}}` +
886 `.ripple{animation:ripple ${LOOP_MS.fish}ms steps(1,end) infinite}@keyframes ripple{0%{opacity:0}72%{opacity:.9}80%{opacity:.5}88%{opacity:0}}`,
887 // A can's drip, and a gardener pacing its row.
888 water: `.drip{animation:drip ${LOOP_MS.drip}ms steps(3,end) infinite}@keyframes drip{from{transform:translateY(0)}to{transform:translateY(3px)}}` + strollCss('pace') + strollCss('pace-l'),
889 // A stroking hand, a pixel at a time, and a rising heart.
890 pet: `.pet{animation:pet ${LOOP_MS.pet}ms steps(2,end) infinite}@keyframes pet{0%,100%{transform:translateX(0)}50%{transform:translateX(2px)}}` +
891 `.heart{animation:heart ${LOOP_MS.heart}ms ease-out infinite}@keyframes heart{0%{transform:translateY(0);opacity:0}6%{opacity:1}40%,100%{transform:translateY(-8px);opacity:0}}`,
892 // A strum, as slow as the work bob, and a floating note.
893 lute: `.strum{animation:strum ${LOOP_MS.strum}ms steps(1,end) infinite}@keyframes strum{0%{transform:translateY(0)}50%{transform:translateY(-1px)}}` +
894 `.note{animation:note ${LOOP_MS.note}ms ease-out infinite}@keyframes note{0%{transform:translate(0,0);opacity:0}12%{opacity:1}100%{transform:translate(3px,-9px);opacity:0}}`,
895 // A page turned now and then.
896 read: `.read-b{opacity:0}.read-a,.read-b{animation:${LOOP_MS.read}ms steps(1,end) infinite}.read-a{animation-name:read-a}.read-b{animation-name:read-b}@keyframes read-a{0%{opacity:0}6%{opacity:1}}@keyframes read-b{0%{opacity:1}6%{opacity:0}}`,
897 // A kite drifting in three steps, as pixel art moves.
898 kite: `.kite-a,.kite-c{opacity:0}.kite-a,.kite-b,.kite-c{animation:${LOOP_MS.kite}ms steps(1,end) infinite}.kite-a{animation-name:kite-a}.kite-b{animation-name:kite-b}.kite-c{animation-name:kite-c}` +
899 '@keyframes kite-a{0%{opacity:1}25%{opacity:0}}@keyframes kite-b{0%{opacity:0}25%{opacity:1}50%{opacity:0}75%{opacity:1}}@keyframes kite-c{0%{opacity:0}50%{opacity:1}75%{opacity:0}}',
900 // Birds hopping and pecking, each to its own rhythm.
901 birds: `.bird-a,.bird-b{animation:${LOOP_MS.birds}ms steps(1,end) infinite}.bird-a{animation-name:bird-a}.bird-b{animation-name:bird-b}` +
902 '@keyframes bird-a{0%{transform:translate(0,0)}20%{transform:translate(1px,-2px)}26%{transform:translate(2px,0)}50%{transform:translate(2px,1px)}56%{transform:translate(2px,0)}64%{transform:translate(2px,1px)}70%{transform:translate(2px,0)}84%{transform:translate(1px,-2px)}90%{transform:translate(0,0)}}' +
903 '@keyframes bird-b{0%{transform:translate(0,0)}10%{transform:translate(0,1px)}16%{transform:translate(0,0)}40%{transform:translate(1px,-2px)}46%{transform:translate(2px,0)}60%{transform:translate(2px,1px)}66%{transform:translate(2px,0)}76%{transform:translate(1px,-2px)}82%{transform:translate(0,0)}}',
904}
905// One on its way to play shows its pose and props only once there (`.ar`), upright till then (`.lv`).
906const ARRIVE_MOTION = '.ar{animation:ar 1ms backwards}@keyframes ar{from{opacity:0}}.lv{opacity:0;animation:lv 1ms backwards}@keyframes lv{from{opacity:1}}'
907// Last, so it wins over each loop's own: every loop takes its worker's clock delay.
908const CLOCKED_LOOPS = '.l{animation-delay:var(--d)}'
909
910// The motion for a drawing with these pastimes in it, and with anyone on the way to one.
911export function motionCss(plays: Iterable<Play>, isArriving = false): string {
912 return BASE_MOTION + [...new Set(plays)].map(p => PLAY_MOTION[p]).join('') + (isArriving ? ARRIVE_MOTION : '') + CLOCKED_LOOPS
913}
914// Everything, as a drawing with every pastime would carry it.
915export const COLONY_MOTION = motionCss(PLAYS, true)
916
917// --- The scenery. ---
918
919// What stands beside a building: the hall's flag, the library's book, an anvil and barrel, smoke, a cart.
920function extrasOf(key: string, x: number, y: number, busy: ReadonlySet<string>): string {
921 if (key === 'hall') return sprite(FLAG.grid, FLAG.colors, x + 29, y - 10)
922 if (key === 'library') return sprite(BOOK.grid, BOOK.colors, x + 17, y + 30)
923 if (key === 'workshop') return sprite(ANVIL.grid, ANVIL.colors, x + 34, y + 34) + sprite(BARREL.grid, BARREL.colors, x - 10, y + 22)
924 if (key === 'forge' && busy.has('forge')) return `<g class="smoke">${[0, 1, 2].map(i => `<rect x="${x + 8 + i}" y="${y - 4 - i * 2}" width="4" height="4" fill="#d6d2cb"/>`).join('')}</g>`
925 if (key === 'mine') return `<path d="M${x + 14} ${y + 18}v8M${x + 20} ${y + 18}v8" stroke="#6f6a63" stroke-width="1.5"/>` + sprite(CART.grid, CART.colors, x + 26, y + 18)
926 return ''
927}
928
929// A venue's scenery, centred at (cx, cy) in the map: the pond with its reeds and a lily pad, the
930// garden's furrows and sprouts, the village cat (its tail flicks now and then).
931const CAT: Art = { grid: ['O...O', 'OOOOO', 'OeOeO', 'OOpOO', '.OOO.', 'OSOSO'], colors: { O: '#e39b4f', S: '#c47a35', e: OUT, p: '#f28aa0' } }
932const TAIL = { a: ['.t', 't.', 't.'], b: ['t.', '.t', 't.'], colors: { t: '#e39b4f' } }
933function venueSvg(key: string, cx: number, cy: number): string {
934 if (key === 'pond') {
935 const reeds = [cx - 24, cx - 21, cx + 21].map((x, i) => `M${x} ${cy - 6 + i * 2}h1.5v${6 - i}h-1.5z`).join('')
936 return (
937 blob(cx, cy, 12, 5, '#3e6b3a') + blob(cx, cy, 11, 4, '#4f95bf') + blob(cx + 2, cy - 2, 5, 1, '#62a6cd') +
938 `<path fill="#9fd3ea" d="M${cx - 8} ${cy - 4}h4v1h-4zM${cx + 6} ${cy + 3}h3v1h-3z"/><path fill="#3d7542" d="${reeds}"/><path fill="#7a4f33" d="M${cx - 24} ${cy - 8}h1.5v2h-1.5zM${cx + 21} ${cy - 3}h1.5v2h-1.5z"/>` +
939 `<path fill="#6fae5a" d="M${cx - 2} ${cy + 3}h5v2h-5zM${cx - 1} ${cy + 2}h2v1h-2z"/>`
940 )
941 }
942 if (key === 'garden') {
943 // Three furrows of sprouts, one tile repeated.
944 const tile = '<path fill="#74532f" d="M0 3h6v2h-6z"/><path fill="#6fbf5a" d="M1 2h2v2h-2zM0 0h1v2h-1zM3 0h1v2h-1z"/>'
945 return (
946 `<pattern id="c-sprouts" x="${cx - 14}" y="${cy - 7}" width="6" height="6" patternUnits="userSpaceOnUse">${tile}</pattern>` +
947 `<rect x="${cx - 17}" y="${cy - 10}" width="34" height="22" fill="#6b4a2e"/><rect x="${cx - 16}" y="${cy - 9}" width="32" height="20" fill="#8a6440"/>` +
948 `<rect x="${cx - 15}" y="${cy - 7}" width="30" height="18" fill="url(#c-sprouts)"/><path fill="#f2a7b8" d="M${cx - 12} ${cy - 8}h2v2h-2zM${cx + 6} ${cy - 8}h2v2h-2z"/>`
949 )
950 }
951 if (key === 'cat') {
952 const x = cx - 5
953 const y = cy - 12
954 return `<ellipse cx="${cx}" cy="${cy}" rx="6" ry="2" fill="#000" fill-opacity=".2"/>` + sprite(CAT.grid, CAT.colors, x, y) + `<g class="tail-a">${sprite(TAIL.a, TAIL.colors, x + 10, y + 4)}</g><g class="tail-b">${sprite(TAIL.b, TAIL.colors, x + 10, y + 4)}</g>`
955 }
956 return ''
957}
958
959// The shapes placed again and again, each drawn once; a tree carries its own shadow.
960function sceneryDefs(): string {
961 const tree = (id: string, art: Art) => `<g id="${id}"><ellipse cx="11" cy="${art.grid.length * P}" rx="9" ry="3" fill="#000" fill-opacity=".18"/>${sprite(art.grid, art.colors, 0, 0)}</g>`
962 return tree('c-tree', TREE) + tree('c-pine', PINE) + tree('c-bush', BUSH) + `<g id="c-tent">${sprite(TENT.grid, TENT.colors, 0, 0)}</g>`
963}
964
965// The scenery: grass, plaza and paths, buildings and their signs, trees and flowers. `vx` is the
966// village's left edge. The same for the same width and busy buildings, so it is kept.
967let sceneryKept: { key: string; svg: string } | null = null
968
969function scenery(w: number, vx: number, busy: ReadonlySet<string>): string {
970 const key = `${w}|${vx}|${busy.has('forge')}`
971 if (sceneryKept?.key === key) return sceneryKept.svg
972 const out: string[] = []
973 out.push(`<rect width="${w}" height="${MAP_TALL}" fill="${GRASS}"/><rect width="${w}" height="${MAP_TALL}" fill="url(#grass)"/>`)
974 const at = (s: Spot): Spot => ({ x: vx + s.x, y: s.y })
975 const fire = at(FIRE_AT)
976
977 // Paths, then the plaza over their ends.
978 const paths = ['hall', 'library', 'workshop', 'tower', 'forge', 'mine', 'tents'].map(k => path(fire, SPOTS[k] ?? FIRE_AT, vx))
979 out.push(`<g fill="${DIRT_RIM}">${paths.map(p => p.rim).join('')}</g><g fill="${DIRT}">${paths.map(p => p.fill).join('')}</g>`)
980 out.push(blob(fire.x, fire.y, 26, 14, DIRT_RIM), blob(fire.x, fire.y, 25, 13, DIRT))
981 for (let i = 0; i < 7; i += 1) out.push(`<rect x="${Math.round(fire.x - 40 + rand(i + 1) * 80)}" y="${Math.round(fire.y - 18 + rand(i + 31) * 36)}" width="2" height="2" fill="#dcbc87"/>`)
982
983 // Reserved ground: nothing grows on buildings, signs, paths or the plaza.
984 const taken: Box[] = BUILDINGS.map(b => ({ x: vx + b.x - 6, y: b.y - 8, w: (Math.max(...b.art.grid.map(r => r.length)) + 6) * P, h: (b.art.grid.length + 8) * P }))
985 taken.push(...BUILDINGS.map(b => ({ x: vx + b.x + b.sign.x - 4, y: b.y + b.sign.y - 8, w: BOARD_SIZE + 8, h: BOARD_SIZE + 20 })))
986 taken.push({ x: fire.x - 58, y: fire.y - 32, w: 116, h: 64 }, { x: vx + 46, y: 120, w: 64, h: 60 })
987 taken.push(...VENUES.map(v => ({ ...v.box, x: vx + v.box.x })))
988 for (const k of Object.keys(SPOTS)) {
989 const s = at(SPOTS[k] ?? FIRE_AT)
990 taken.push({ x: Math.min(s.x, fire.x) - 8, y: fire.y - 8, w: Math.abs(s.x - fire.x) + 16, h: 16 }, { x: s.x - 8, y: Math.min(s.y, fire.y) - 8, w: 16, h: Math.abs(s.y - fire.y) + 22 })
991 }
992 const isFree = (b: Box) => !taken.some(t => b.x < t.x + t.w && b.x + b.w > t.x && b.y < t.y + t.h && b.y + b.h > t.y)
993
994 // Flowers and tufts, scattered.
995 const flowers = ['#f2a7b8', '#f5e27a', '#fbf3e0', '#b9a7f0']
996 const dots = new Map<string, string>()
997 const dot = (color: string, d: string) => dots.set(color, (dots.get(color) ?? '') + d)
998 for (let i = 0; i < Math.round(w / 5); i += 1) {
999 const x = Math.round(rand(i * 3 + 7) * (w - 6))
1000 const y = Math.round(rand(i * 3 + 8) * (MAP_TALL - 6))
1001 if (!isFree({ x, y, w: 6, h: 6 })) continue
1002 if (rand(i * 3 + 9) < 0.45) dot(flowers[i % flowers.length] ?? '#fbf3e0', `M${x} ${y}h2v2h-2z`)
1003 else dot('#79ad62', `M${x} ${y + 2}h2v2h-2zM${x + 4} ${y + 2}h2v2h-2zM${x + 2} ${y}h2v2h-2z`)
1004 }
1005 out.push(...[...dots].map(([color, d]) => `<path fill="${color}" d="${d}"/>`))
1006
1007 // Trees: a few placed in the village, and forest filling whatever lies beyond it.
1008 const trees: { id: string; h: number; x: number; y: number }[] = []
1009 const plant = (art: Art, x: number, y: number) => trees.push({ id: art === TREE ? 'c-tree' : art === PINE ? 'c-pine' : 'c-bush', h: art.grid.length * P, x, y })
1010 plant(TREE, vx + 96, 4)
1011 plant(PINE, vx + 202, 2)
1012 plant(PINE, vx + 8, 14)
1013 plant(TREE, vx + 300, 16)
1014 plant(TREE, vx + 100, 52)
1015 plant(PINE, vx + 206, 44)
1016 plant(PINE, vx + 12, 140)
1017 plant(TREE, vx + 288, 142)
1018 plant(BUSH, vx + 138, 154)
1019 plant(BUSH, vx + 18, 176)
1020 plant(BUSH, vx + 186, 154)
1021 plant(TREE, vx + 154, 172)
1022 plant(BUSH, vx + 266, 178)
1023 plant(PINE, vx + 300, 168)
1024 for (let gx = 0; gx * 24 < w + 24; gx += 1) {
1025 for (let gy = 0; gy * 28 < MAP_TALL + 20; gy += 1) {
1026 const x = gx * 24 + Math.round(rand(gx * 17 + gy) * 8) - 4
1027 const y = gy * 28 + Math.round(rand(gx * 5 + gy * 13) * 10) - 8
1028 if (x > vx - 22 && x < vx + VILLAGE_W) continue
1029 const pick = rand(gx * 7 + gy * 3)
1030 plant(pick < 0.45 ? PINE : pick < 0.85 ? TREE : BUSH, x, y)
1031 }
1032 }
1033
1034 // Everything that stands, back to front; a building's sign stands with it.
1035 const standing = [
1036 ...BUILDINGS.map(b => ({
1037 y: b.y + b.art.grid.length * P,
1038 svg: sprite(b.art.grid, b.art.colors, vx + b.x, b.y) + extrasOf(b.key, vx + b.x, b.y, busy) + signSvg(b.task, vx + b.x + b.sign.x, b.y + b.sign.y, b.wall === null ? null : vx + b.x + b.wall),
1039 })),
1040 ...trees.map(t => ({ y: t.y + t.h, svg: useArt(t.id, t.x, t.y) })),
1041 { y: 152, svg: useArt('c-tent', vx + 60, 134) + useArt('c-tent', vx + 84, 140) },
1042 { y: fire.y + 8, svg: sprite(BENCH_V.grid, BENCH_V.colors, fire.x - 30, fire.y - 4) + sprite(BENCH_V.grid, BENCH_V.colors, fire.x + 26, fire.y - 4) + sprite(BENCH_H.grid, BENCH_H.colors, fire.x - 7, fire.y + 16) },
1043 { y: fire.y + 4, svg: sprite(FIRE.a, FIRE.colors, fire.x - 8, fire.y - 8) + `<g class="fire-b">${sprite(FIRE.b, FIRE.colors, fire.x - 8, fire.y - 8)}</g>` },
1044 ...VENUES.map(v => ({ y: v.key === 'cat' ? v.at.y : 0, svg: venueSvg(v.key, vx + v.at.x, v.at.y) })),
1045 ]
1046 standing.sort((a, b) => a.y - b.y)
1047 out.push(...standing.map(s => s.svg))
1048 sceneryKept = { key, svg: out.join('') }
1049 return sceneryKept.svg
1050}
1051
1052// --- The line over the map: what everyone is doing, in words, once. ---
1053
1054const DOING: Readonly<Record<Task, string>> = { develop: 'developing', research: 'researching', review: 'reviewing', test: 'testing', plan: 'planning', debug: 'debugging' }
1055
1056// How many workers are doing what: everyone, drawn or not.
1057export function countOf(workers: readonly SceneWorker[]): Count {
1058 const count: Count = { tasks: {}, wait: 0, chill: 0, done: 0 }
1059 for (const w of workers) {
1060 if (w.look === 'work') count.tasks[w.task] = (count.tasks[w.task] ?? 0) + 1
1061 else if (w.look === 'wait') count.wait += 1
1062 else if (w.look === 'chill') count.chill += 1
1063 else count.done += 1
1064 }
1065 return count
1066}
1067
1068// The line's parts: who needs the person first, then the busiest task, then the rest:
1069// "1 needs you", "2 developing · 1 researching · 3 chilling · 1 done". Past `room` pixels, the
1070// tasks fold into "3 at work".
1071export function tallyParts(count: Count, room = Infinity): { urgent: string; rest: string } {
1072 const urgent = count.wait ? `${count.wait} ${count.wait === 1 ? 'needs' : 'need'} you` : ''
1073 const after = [count.chill ? `${count.chill} chilling` : '', count.done ? `${count.done} done` : '']
1074 const tasks = (Object.entries(count.tasks) as [Task, number][]).sort((a, b) => b[1] - a[1]).map(([task, n]) => `${n} ${DOING[task]}`)
1075 const join = (list: string[]) => list.filter(Boolean).join(' · ')
1076 const rest = join([...tasks, ...after])
1077 if (measure(join([urgent, rest]), 11.5) <= room) return { urgent, rest }
1078 const working = Object.values(count.tasks).reduce((a, b) => a + (b ?? 0), 0)
1079 return { urgent, rest: join([working ? `${working} at work` : '', ...after]) }
1080}
1081
1082export function tallyOf(workers: readonly SceneWorker[] | Count, room = Infinity): string {
1083 const parts = tallyParts(Array.isArray(workers) ? countOf(workers) : (workers as Count), room)
1084 return [parts.urgent, parts.rest].filter(Boolean).join(' · ')
1085}
1086
1087// A drawing the page draws for a Client is held to 100,000 characters as it travels (its source
1088// escaped, beside its alt); past that the region is dropped. Kept under, with room to spare.
1089export const DRAWING_LIMIT = 96_000
1090const RANK: Readonly<Record<Look, number>> = { wait: 0, work: 1, chill: 2, failed: 3, sleep: 4 }
1091
1092// The colony as one SVG `scene.width` pixels wide, its walks and loops carried on to `now`. A crowd
1093// too big to draw within the limit leaves out the least busy first; the line over the map still
1094// counts everyone.
1095export function colonySvg(scene: ColonyScene, now: number): { source: string; height: number; alt: string } {
1096 let shown = scene.workers
1097 for (;;) {
1098 const drawing = drawColony(scene, shown, now)
1099 const size = JSON.stringify(drawing.source).length
1100 if (size <= DRAWING_LIMIT || shown.length === 0) return drawing
1101 const drop = Math.max(1, Math.ceil((shown.length * (size - DRAWING_LIMIT)) / size))
1102 const order = shown.map((w, i) => ({ w, i })).sort((a, b) => RANK[b.w.look] - RANK[a.w.look] || b.i - a.i)
1103 const out = new Set(order.slice(0, drop).map(o => o.i))
1104 shown = shown.filter((_, i) => !out.has(i))
1105 }
1106}
1107
1108function drawColony(scene: ColonyScene, workers: readonly SceneWorker[], now: number): { source: string; height: number; alt: string } {
1109 const { width } = scene
1110 const { k, w, vx } = mapOf(width)
1111 const mapW = width - 2 * PAD
1112 const mapH = Math.round(MAP_TALL * k)
1113 const top = 4
1114 const out: string[] = []
1115
1116 // The header: who needs the person leads, in the waiting colour.
1117 const count = scene.count ?? countOf(scene.workers)
1118 const tally = tallyParts(count, mapW - 62)
1119 const urgent = tally.urgent ? `<tspan class="wait-t">${escape(tally.urgent)}</tspan>${tally.rest ? ' · ' : ''}` : ''
1120 out.push(`<text x="${PAD}" y="${top + 16}" font-size="12.5" font-weight="650">Colony</text><text x="${PAD + 58}" y="${top + 16}" font-size="11.5" class="faint">${urgent}${escape(tally.rest)}</text>`)
1121
1122 const my = top + HEAD
1123 out.push(`<defs><pattern id="grass" width="16" height="16" patternUnits="userSpaceOnUse"><rect x="2" y="3" width="2" height="2" fill="#679c57"/><rect x="10" y="9" width="2" height="2" fill="#517f44"/><rect x="6" y="13" width="2" height="2" fill="#679c57"/><rect x="13" y="1" width="2" height="2" fill="#517f44"/></pattern>`)
1124 out.push(sceneryDefs())
1125 out.push(`<clipPath id="colony"><rect x="${PAD}" y="${my}" width="${mapW}" height="${mapH}" rx="10"/></clipPath></defs>`)
1126 out.push(`<g clip-path="url(#colony)"><g class="village" transform="translate(${PAD} ${my}) scale(${k.toFixed(3)})">`)
1127 const busy = new Set(workers.filter(wk => wk.look === 'work').map(wk => BUILDING_OF[wk.task]))
1128 out.push(scenery(w, vx, busy))
1129
1130 // Lower on the map is nearer: drawn last, in front; names over everyone. Names keep to about
1131 // 9 pixels on screen however small the village is drawn.
1132 const defs = new Defs()
1133 const walks: string[] = []
1134 const nameSize = nameSizeOf(width)
1135 const names = placeNames(workers, width)
1136 const drawn = [...workers].sort((a, b) => a.y - b.y).map(wk => workerSvg(wk, vx, now, defs, names.get(wk.key), walks))
1137 if (defs.svg.length > 0) out.push(`<defs>${defs.svg.join('')}</defs>`)
1138 out.push(...drawn.map(d => d.body), ...drawn.map(d => d.tag))
1139 out.push(`</g><rect x="${PAD + 0.5}" y="${my + 0.5}" width="${mapW - 1}" height="${mapH - 1}" rx="10" fill="none" stroke="#000" stroke-opacity=".25"/></g>`)
1140
1141 // What follows the clock in the scenery (kept between drawings) gets its phase here: the fire's
1142 // flicker and the forge's smoke carry on through a redraw instead of starting together.
1143 const beat = (ms: number, lag = 0) => `-${(now + ms * 1_000 - lag) % ms}ms`
1144 const clocked = `.nm{font-size:${nameSize}px}.fire-b{animation-delay:${beat(500)}}.tail-a,.tail-b{animation-delay:${beat(LOOP_MS.tail)}}${[0, 1, 2].map(i => `.smoke rect:nth-child(${i + 1}){animation-delay:${beat(3_200, i * 1_067)}}`).join('')}`
1145
1146 const height = my + mapH + 12
1147 const plays = workers.flatMap(wk => (wk.look === 'chill' ? [pastimeAt(wk.key, { x: wk.x, y: wk.y }).play] : []))
1148 const isArriving = workers.some(wk => (wk.look === 'chill' || names.has(wk.key)) && now - wk.since < wk.ms)
1149 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${THEME}${motionCss(plays, isArriving)}${clocked}${walks.join('')}${scene.isStill ? STILL : ''}</style>${out.join('')}</svg>`
1150 return { source, height, alt: clean(`Colony: ${tallyOf(count) || 'nobody yet'}.`) }
1151}
1152hooks/svg.ts 679 lines1import type { FleetAgent, FleetLimit, FleetSession } from '../types'
2import type { Flag, FleetSummary, Progress } from './describe'
3import { stateLine, limitName, limitTone, plain, taskText, plural, isPlanLimit, resetIn, fullIn, agentFlag, agentProgress, isAgentLive, isBusy, crewsOf, pickName, progressLabel, sessionProgress, cacheLeft, cacheText, folderOf, modeName, receiptText, recentLoops, slowFor, formatDuration, formatTokens, modelName, nameOf, percentOf, progressOf, spriteKind, totalTokens } from './describe'
4
5// The desktop draws an Svg as an image: its CSS animates with no redraws from the mod,
6// and prefers-color-scheme and prefers-reduced-motion reach it from the page.
7
8const FONT = `ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Inter, sans-serif`
9
10// Text without the characters XML 1.0 forbids or the app refuses (C0 and C1 control codes and DEL
11// from a command's output, lone surrogates from a cut string): an Svg, or its alt, holding one is
12// refused, and the panel goes blank.
13export const clean = (text: string): string =>
14 text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\uFFFE\uFFFF]/g, '').replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '')
15
16// Text into SVG: cleaned, and its markup escaped.
17export const escape = (text: string): string =>
18 clean(text)
19 .replace(/&/g, '&')
20 .replace(/</g, '<')
21 .replace(/>/g, '>')
22 .replace(/"/g, '"')
23
24// Width of a string in pixels, close enough to fit text without a renderer.
25export function measure(text: string, size: number, isBold = false): number {
26 const em = [...text].reduce((sum, ch) => {
27 if ("iljtf.,:;|!'` ".includes(ch)) return sum + 0.3
28 if ('mwMW@%'.includes(ch)) return sum + 0.84
29 if (ch >= 'A' && ch <= 'Z') return sum + 0.65
30 if (ch >= '0' && ch <= '9') return sum + 0.56
31 return sum + 0.53
32 }, 0)
33 return em * size * (isBold ? 1.06 : 1)
34}
35
36export function fit(text: string, width: number, size: number, isBold = false): string {
37 if (width <= 0) return ''
38 if (measure(text, size, isBold) <= width) return text
39 const chars = [...text]
40 while (chars.length > 1 && measure(`${chars.join('')}…`, size, isBold) > width) chars.pop()
41 return `${chars.join('').trimEnd()}…`
42}
43
44// The desktop draws a pane's text columns about 8 CSS pixels wide (measured from its side panel).
45const PX_PER_COLUMN = 8
46
47export const widthFor = (columns: number, min = 360, max = 1400): number => Math.round(Math.min(max, Math.max(min, columns * PX_PER_COLUMN)))
48
49// Colours as roles, dark by default and redefined for a light page.
50export const THEME = [
51 'svg{--fg:#ECEAE6;--muted:#A9A59E;--faint:#8A867F;--line:rgba(236,234,230,.12);--panel:rgba(236,234,230,.045);',
52 '--work:#E08A68;--wait:#E7B65C;--ok:#86C496;--err:#E8786F;--idle:#8E8A83;--eye:#1E1D1B;--w1:#F08A4B;--w2:#E7B65C;--w3:#F2E6A6}',
53 '@media (prefers-color-scheme:light){svg{--fg:#22211F;--muted:#5F5B55;--faint:#75716A;--line:rgba(34,33,31,.12);--panel:rgba(34,33,31,.04);',
54 '--work:#A9512F;--wait:#946410;--ok:#2F8A50;--err:#C2453B;--idle:#9A958D;--eye:#FFFFFF;--w1:#C2571A;--w2:#946410;--w3:#8F7A0A}}',
55 `text{font-family:${FONT};fill:var(--fg)}.muted{fill:var(--muted)}.faint{fill:var(--faint)}.wait-t{fill:var(--wait)}.err-t{fill:var(--err)}`,
56 // A worker's role colour, from the hue its group carries: a light body and a deeper hat.
57 '.role{--rb:hsl(var(--h) 62% 70%);--rh:hsl(var(--h) 50% 47%)}',
58 '@media (prefers-color-scheme:light){.role{--rb:hsl(var(--h) 52% 43%);--rh:hsl(var(--h) 58% 31%)}}',
59].join('')
60
61// Each kind of worker wears its own hat in its own colour; what it is doing shows in the prop it holds.
62// Hues stay clear of the state colours: amber (needs you), green (done) and red (failed).
63export type Role = { hat: string; hue: number }
64
65export const ROLES: Readonly<Record<string, Role>> = {
66 lead: { hat: 'crown', hue: 20 },
67 'haiku-quick': { hat: 'propeller', hue: 80 },
68 'haiku-worker': { hat: 'cap', hue: 160 },
69 Explore: { hat: 'ranger', hue: 192 },
70 'sonnet-worker': { hat: 'hardhat', hue: 222 },
71 Plan: { hat: 'mortarboard', hue: 250 },
72 'opus-deep': { hat: 'wizard', hue: 278 },
73 'opus-worker': { hat: 'tophat', hue: 308 },
74 'general-purpose': { hat: 'beanie', hue: 340 },
75 claude: { hat: 'beanie', hue: 340 },
76}
77
78const SPARE_HATS = ['bucket', 'headband', 'beanie']
79// Hues free for agent types with no role of their own, between the ones taken.
80const SPARE_HUES = [95, 176, 207, 236, 264, 293, 324, 352]
81
82// The main agent is `lead`; a subagent goes by its type. Unknown types still get a steady look of their own.
83export function roleOf(type = 'lead'): Role {
84 const known = ROLES[type]
85 if (known) return known
86 const hash = [...type].reduce((n, ch) => (n * 31 + ch.charCodeAt(0)) >>> 0, 7)
87 return { hat: SPARE_HATS[hash % SPARE_HATS.length] ?? 'beanie', hue: SPARE_HUES[hash % SPARE_HUES.length] ?? 348 }
88}
89
90const ACCENT = 'fill="var(--fg)" fill-opacity=".8"'
91
92// Hats in the worker's 24-unit grid, sitting on a head whose top is at y=6. Brims and tips keep
93// left of x=17, so the prop on the right stays clear. Only the propeller moves, and only at work.
94function hat(name: string, isBusy: boolean, delayMs = 0): string {
95 const h = 'fill="var(--rh)"'
96 switch (name) {
97 case 'crown':
98 return `<path d="M7.2 7.8 6.6 3.4l2.9 2.2L12 2.2l2.5 3.4 2.9-2.2-.6 4.4z" ${h}/><circle cx="6.6" cy="3.1" r=".9" ${h}/><circle cx="12" cy="1.9" r=".9" ${h}/><circle cx="17.4" cy="3.1" r=".9" ${h}/><circle cx="12" cy="6.2" r=".75" ${ACCENT}/>`
99 case 'wizard':
100 return `<path d="M5.8 7.6Q12 9.2 18 7.6L8.6-.8z" ${h}/><path d="M11.4 4.2l.4.9 1 .1-.7.6.2 1-.9-.5-.9.5.2-1-.7-.6 1-.1z" ${ACCENT}/>`
101 case 'tophat':
102 return `<rect x="8.4" y=".8" width="7.2" height="6.2" rx=".6" ${h}/><rect x="8.4" y="4.6" width="7.2" height="1.2" fill="var(--fg)" fill-opacity=".5"/><rect x="5.8" y="6.6" width="12.4" height="1.6" rx=".8" ${h}/>`
103 case 'hardhat':
104 return `<path d="M6.6 7.4C6.6 3.8 9 2.2 12 2.2s5.4 1.6 5.4 5.2z" ${h}/><path d="M12 2.6v4.2" stroke="var(--fg)" stroke-opacity=".45" stroke-width="1"/><rect x="5" y="6.6" width="14" height="1.6" rx=".8" ${h}/>`
105 case 'cap':
106 return `<path d="M7 7.6C7 4.4 9.2 2.8 12 2.8s5 1.6 5 4.8z" ${h}/><path d="M8.6 6.6H3.6c-.7 0-.7 1 0 1.2l5 .2z" ${h}/><circle cx="12" cy="2.8" r=".7" ${ACCENT}/>`
107 case 'propeller':
108 return `<path d="M7.2 7.6C7.2 4.6 9.4 3.4 12 3.4s4.8 1.2 4.8 4.2z" ${h}/><path d="M12 3.6V1.8" stroke="var(--fg)" stroke-opacity=".7" stroke-width=".8"/><ellipse class="${isBusy ? 'spin' : ''}" style="animation-delay:-${delayMs % 900}ms" cx="12" cy="1.5" rx="3.6" ry=".8" ${ACCENT}/>`
109 case 'ranger':
110 return `<path d="M8 6.6 8.6 2.8C9.6 2 11 2.6 12 3.2 13 2.6 14.4 2 15.4 2.8L16 6.6z" ${h}/><path d="M8 5.4h8" stroke="var(--fg)" stroke-opacity=".45" stroke-width=".9"/><path d="M3.6 5.6C5 7.6 8 7.8 12 7.8s7-.2 8.4-2.2C19 8.6 16 9 12 9S5 8.6 3.6 5.6z" ${h}/>`
111 case 'mortarboard':
112 return `<path d="M8.2 7.6V5.4h7.6v2.2z" ${h}/><path d="M5 4.2 12 1.6l7 2.6L12 6.8z" ${h}/><path d="M7 4.6v3.4" stroke="var(--fg)" stroke-opacity=".8" stroke-width=".7"/><circle cx="7" cy="8.2" r=".75" ${ACCENT}/>`
113 case 'bucket':
114 return `<path d="M7.8 7C8 4.4 9.7 3.2 12 3.2s4 1.2 4.2 3.8z" ${h}/><path d="M5.6 8.4 7.2 6.4h9.6l1.6 2z" ${h}/>`
115 case 'headband':
116 return `<path d="M6.6 9.2C8 8.2 16 8.2 17.4 9.2L17.6 7.4C16 6.4 8 6.4 6.4 7.4z" ${h}/><path d="M6.6 8.2 3.6 6.6l.2 2.2zM6.6 8.6 4 10.2l1.7.7z" ${h}/>`
117 default:
118 return `<path d="M7 7.6C7 4.4 9.2 3 12 3s5 1.4 5 4.6z" ${h}/><rect x="6.6" y="6.2" width="10.8" height="1.9" rx=".95" ${h}/><rect x="6.6" y="6.2" width="10.8" height="1.9" rx=".95" fill="var(--fg)" fill-opacity=".18"/><circle cx="12" cy="2.6" r="1.2" ${ACCENT}/>`
119 }
120}
121
122// Each worker's motion says what it is doing; slow and small, so a dock of them stays calm.
123// Only a session waiting on the person moves to be noticed. Reduced motion stills them all.
124export const MOTION = [
125 '.c{transform-box:fill-box;transform-origin:50% 100%}',
126 '.bob{animation:bob 1.6s cubic-bezier(.45,0,.55,1) infinite}',
127 '.hop{animation:hop 1.3s cubic-bezier(.33,0,.2,1) infinite}',
128 '.eyes{transform-box:fill-box;transform-origin:50% 50%;animation:blink 4.8s infinite}',
129 '.scan{animation:scan 2.4s ease-in-out infinite,blink 4.8s infinite}',
130 '.tool{transform-box:fill-box;transform-origin:50% 100%;animation:wiggle 1.4s ease-in-out infinite}',
131 '.orbit{transform-box:view-box;animation:orbit 2.2s linear infinite}@keyframes orbit{from{transform:rotate(0)}to{transform:rotate(360deg)}}',
132 '.pulse{animation:pulse 1.8s ease-in-out infinite}',
133 '.dots circle{animation:dot 1.4s ease-in-out infinite;opacity:.25}',
134 // A propeller seen side-on: its blade narrows and widens as it turns.
135 '.spin{transform-box:fill-box;transform-origin:50% 50%;animation:spin .9s infinite}@keyframes spin{0%,100%{transform:scaleX(1);animation-timing-function:cubic-bezier(.45,0,1,1)}50%{transform:scaleX(.15);animation-timing-function:cubic-bezier(0,0,.55,1)}}',
136 '@keyframes bob{50%{transform:translateY(-1.5px)}}',
137 '@keyframes hop{0%,100%{transform:translateY(0)}40%{transform:translateY(-2px)}60%{transform:translateY(-2px)}}',
138 '@keyframes blink{0%,94%,100%{transform:scaleY(1)}97%{transform:scaleY(.15)}}',
139 '@keyframes scan{0%,100%{transform:translateX(-1px)}50%{transform:translateX(1.2px)}}',
140 '@keyframes wiggle{0%,100%{transform:rotate(-6deg)}50%{transform:rotate(6deg)}}',
141 '@keyframes pulse{0%,100%{opacity:.35}50%{opacity:1}}',
142 '@keyframes dot{0%,100%{opacity:.25}40%{opacity:1}}',
143 // Reduced motion keeps what only fades (the waiting badge, the dots), slower, and stills all movement.
144 '@media (prefers-reduced-motion:reduce){.c,.eyes,.scan,.tool,.orbit,.spin{animation:none!important}.pulse{animation-duration:3s}.dots circle{animation-duration:2.4s}}',
145].join('')
146
147export const STYLE = THEME + MOTION
148
149// At work a worker wears its role colour; resting it greys, and a failed one turns red.
150const colorOf = (state: string): string => (state === 'failed' ? 'var(--err)' : state === 'idle' || state === 'ended' ? 'var(--idle)' : 'var(--rb)')
151
152// A small round worker, `size` wide, its feet at (x, y + size*0.8). Its hat and colour say
153// what kind of worker it is (`role`: 'lead' for a session's main agent, else the subagent type);
154// its motion and the prop beside it say what it is doing. `delay` keeps a dock of them out of step.
155export function critter(kind: string, x: number, y: number, size: number, delayMs: number, state: string, role = 'lead'): string {
156 const s = size / 24
157 const body = colorOf(state)
158 const { hat: hatName, hue } = roleOf(role)
159 // Negative: each redraw restarts the motion, and a positive delay would hold a worker still first.
160 const d = `style="animation-delay:-${delayMs}ms"`
161 // Each dot a beat behind the one before, its phase from the clock like everything else: a positive
162 // delay would hold the later dots still for a moment after every redraw.
163 const dot = (i: number) => `style="animation-delay:-${(delayMs - i * 200 + 1_400_000_000) % 1_400}ms"`
164 const isIdle = kind === 'idle' || kind === 'ended'
165 const motion = kind === 'running' ? 'hop' : isIdle || kind === 'waiting' ? '' : 'bob'
166 const eyes = isIdle
167 ? `<g fill="none" stroke="var(--eye)" stroke-width="1.6" stroke-linecap="round"><path d="M8.2 12.6h2.6M13.2 12.6h2.6"/></g>`
168 : kind === 'done'
169 ? `<g fill="none" stroke="var(--eye)" stroke-width="1.6" stroke-linecap="round"><path d="M8 13l1.5-1.4 1.5 1.4M13 13l1.5-1.4 1.5 1.4"/></g>`
170 : kind === 'failed'
171 ? `<g stroke="var(--eye)" stroke-width="1.5" stroke-linecap="round"><path d="M8.3 11.3l2.2 2.2M10.5 11.3l-2.2 2.2M13.5 11.3l2.2 2.2M15.7 11.3l-2.2 2.2"/></g>`
172 : `<g class="${kind === 'reading' || kind === 'searching' ? 'scan' : 'eyes'}" ${d}><circle cx="9.6" cy="12.4" r="1.45" fill="var(--eye)"/><circle cx="14.4" cy="12.4" r="1.45" fill="var(--eye)"/></g>`
173 const prop =
174 kind === 'editing'
175 ? `<g class="tool" ${d}><path d="M20.2 5.2l2.1 2.1-5.2 5.2-2.6.5.5-2.6z" fill="var(--fg)" fill-opacity=".85"/></g>`
176 : kind === 'reading'
177 ? `<g><rect x="17.4" y="10.4" width="6" height="7.2" rx="1" fill="var(--fg)" fill-opacity=".8"/><path d="M18.6 12.4h3.6M18.6 14h3.6M18.6 15.6h2.4" stroke="var(--eye)" stroke-width=".8"/></g>`
178 : kind === 'searching'
179 ? `<g class="orbit" style="transform-origin:20px 8px;animation-delay:-${delayMs}ms"><circle cx="20.2" cy="6.6" r="2.4" fill="none" stroke="var(--fg)" stroke-width="1.4"/><path d="M21.9 8.3l1.6 1.6" stroke="var(--fg)" stroke-width="1.4" stroke-linecap="round"/></g>`
180 : kind === 'browsing' || kind === 'delegating'
181 ? `<g class="dots" fill="var(--fg)"><circle cx="18.4" cy="5.4" r="1.05" ${dot(0)}/><circle cx="20.9" cy="5.4" r="1.05" ${dot(1)}/><circle cx="23.4" cy="5.4" r="1.05" ${dot(2)}/></g>`
182 : kind === 'thinking'
183 ? `<g class="dots" fill="var(--fg)"><circle cx="17.8" cy="5" r=".95" ${dot(0)}/><circle cx="20.2" cy="3.9" r="1.2" ${dot(1)}/><circle cx="22.9" cy="2.5" r="1.5" ${dot(2)}/></g>`
184 : kind === 'running'
185 ? `<path d="M1 18.6h3M.2 15.8h2.4" stroke="var(--fg)" stroke-opacity=".5" stroke-width="1.2" stroke-linecap="round"/>`
186 : kind === 'waiting'
187 ? `<g class="pulse"><circle cx="20.6" cy="5" r="3.6" fill="var(--wait)"/><path d="M20.6 3.1v2.3" stroke="var(--eye)" stroke-width="1.4" stroke-linecap="round"/><circle cx="20.6" cy="6.9" r=".8" fill="var(--eye)"/></g>`
188 : isIdle
189 ? `<text class="faint" x="18" y="8" font-size="7" font-weight="700" opacity=".7">z</text><text class="faint" x="21" y="4.5" font-size="5.5" font-weight="700" opacity=".45">z</text>`
190 : ''
191 const isResting = state === 'idle' || state === 'ended'
192 return [
193 // A finished subagent steps back: the same worker, faded.
194 `<g class="role" transform="translate(${+x.toFixed(1)} ${+y.toFixed(1)}) scale(${+s.toFixed(3)})" style="--h:${hue}"${state === 'done' ? ' opacity=".55"' : ''}>`,
195 `<ellipse cx="12" cy="20.4" rx="6.4" ry="1.3" fill="var(--fg)" fill-opacity=".1"/>`,
196 `<g class="c ${motion}" ${d}>`,
197 `<path d="M4.6 14.2C4.6 9.2 7.9 6 12 6s7.4 3.2 7.4 8.2c0 3.7-2.6 5.8-7.4 5.8s-7.4-2.1-7.4-5.8z" fill="${body}"/>`,
198 eyes,
199 `<g transform="translate(12 7.6) scale(1.18) translate(-12 -7.6)"${isResting ? ' opacity=".7"' : ''}>${hat(hatName, state === 'working', delayMs)}</g>`,
200 `</g>`,
201 // Props sit a little right, clear of the hats.
202 prop ? `<g transform="translate(2 0)">${prop}</g>` : '',
203 `</g>`,
204 ].join('')
205}
206
207// A hairline track with a fill; colour by level.
208function bar(x: number, y: number, width: number, fraction: number, h = 3): string {
209 const f = Math.max(0, Math.min(1, fraction))
210 const color = f >= 0.85 ? 'var(--err)' : f >= 0.7 ? 'var(--wait)' : 'var(--fg)'
211 const opacity = f >= 0.7 ? 1 : 0.55
212 return `<rect x="${x}" y="${y}" width="${width}" height="${h}" rx="${h / 2}" fill="var(--fg)" fill-opacity=".1"/><rect x="${x}" y="${y}" width="${Math.max(f > 0 ? h : 0, Math.round(width * f))}" height="${h}" rx="${h / 2}" fill="${color}" fill-opacity="${opacity}"/>`
213}
214
215// The motion's phase follows the clock: each redraw restarts CSS animations, and a delay this far
216// back carries a worker on mid-move instead of snapping it to the start. It wraps once a day.
217const beatOf = (now: number): number => now % 86_400_000
218
219const STILL = '*{animation:none!important}.dots circle{opacity:.7}.smoke rect{opacity:.5}'
220
221// `isStill` is the person's Still setting: everything holds still, whatever the OS asks.
222export const svg = (width: number, height: number, body: string, isStill: boolean, extraStyle = ''): string =>
223 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${STYLE}${extraStyle}${isStill ? STILL : ''}</style>${body}</svg>`
224
225export const ctxOf = (s: FleetSession): number => (s.contextPercent !== null ? s.contextPercent / 100 : s.contextWindow ? s.contextTokens / s.contextWindow : 0)
226
227
228// The main agent's prompt cache: a ring that empties as the cache ages, and the minutes left.
229// Amber in its last stretch, a dashed empty ring once cold (the next prompt re-sends everything).
230function cacheBadge(x: number, y: number, s: FleetSession, now: number): string {
231 const left = cacheLeft(s, now)
232 if (left === null) return ''
233 const r = 4
234 const c = 2 * Math.PI * r
235 const f = left.ms / left.ttl
236 const isCold = left.ms <= 0
237 const isLow = !isCold && f <= 0.15
238 const color = isLow ? 'var(--wait)' : 'var(--fg)'
239 const track = `<circle cx="${x + r}" cy="${y}" r="${r}" fill="none" stroke="var(--fg)" stroke-opacity="${isCold ? 0.35 : 0.14}" stroke-width="1.6"${isCold ? ' stroke-dasharray="1.6 1.6"' : ''}/>`
240 const fill = isCold ? '' : `<circle cx="${x + r}" cy="${y}" r="${r}" fill="none" stroke="${color}" stroke-opacity="${isLow ? 1 : 0.6}" stroke-width="1.6" stroke-dasharray="${(c * f).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 ${x + r} ${y})"/>`
241 return `${track}${fill}<text x="${x + 2 * r + 4}" y="${y + 3.6}" font-size="10.5" class="${isLow ? 'wait-t' : 'faint'}">${cacheText(left)}</text>`
242}
243
244// --- The band: a dock of busy sessions above the prompt, idle ones folded into one pill. ---
245
246const CHIP_H = 46
247const CHIP_GAP = 8
248const CHIP_MIN = 230
249// A chip for a session that needs the person may shrink this far before it folds.
250const CHIP_WAIT_MIN = 170
251const CHIP_MAX = 320
252export type BandLayout = { chips: number; chipW: number; hidden: number; pillW: number; pillText: string; pillWait: string; isTight: boolean }
253
254// The folded pill's words and width: who needs the person first (in the wait colour), then the idle
255// count, and busy sessions with no room for a chip.
256const pillOf = (idle: number, hidden: number, hiddenWaiting: number): { wait: string; text: string; width: number } => {
257 const wait = hiddenWaiting > 0 ? `${hiddenWaiting} need${hiddenWaiting === 1 ? 's' : ''} you` : ''
258 const text = [idle > 0 ? `${idle} idle` : '', hidden - hiddenWaiting > 0 ? `+${hidden - hiddenWaiting} busy` : ''].filter(Boolean).join(' · ')
259 const all = [wait, text].filter(Boolean).join(' · ')
260 const sleepers = Math.max(1, Math.min(3, idle))
261 return { wait, text, width: all ? Math.ceil(14 + 20 + (sleepers - 1) * 13 + 10 + measure(all, 11.5) + 14) : 0 }
262}
263
264// When even one chip and the full pill don't fit, the pill shrinks to a count with no workers on it:
265// a session that needs the person keeps its chip.
266const tightPillOf = (idle: number, hidden: number, hiddenWaiting: number): { wait: string; text: string; width: number } => {
267 const wait = hiddenWaiting > 0 ? `${hiddenWaiting} need${hiddenWaiting === 1 ? 's' : ''} you` : ''
268 const more = idle + hidden - hiddenWaiting
269 const text = more > 0 ? (wait ? `+${more}` : `+${more} more`) : ''
270 const all = [wait, text].filter(Boolean).join(' · ')
271 return { wait, text, width: all ? Math.ceil(12 + measure(all, 11.5) + 12) : 0 }
272}
273
274// `waiting` of the `busy` sessions need the person and come first: they are the last to fold.
275export function bandLayout(busy: number, idle: number, width: number, waiting = 0): BandLayout {
276 const fit = (chips: number, isTight: boolean): BandLayout | null => {
277 const pill = (isTight ? tightPillOf : pillOf)(idle, busy - chips, Math.max(0, waiting - chips))
278 const room = width - (pill.width ? pill.width + CHIP_GAP : 0)
279 const min = chips <= waiting ? CHIP_WAIT_MIN : CHIP_MIN
280 if (chips > 0 && room < chips * min + (chips - 1) * CHIP_GAP) return null
281 const chipW = chips > 0 ? Math.min(CHIP_MAX, Math.floor((room - (chips - 1) * CHIP_GAP) / chips)) : 0
282 return { chips, chipW, hidden: busy - chips, pillW: pill.width, pillText: pill.text, pillWait: pill.wait, isTight }
283 }
284 let found: BandLayout | null = null
285 for (let chips = busy; chips >= 0 && found === null; chips -= 1) found = fit(chips, false)
286 const wanted = Math.min(waiting, busy)
287 if (found !== null && found.chips >= wanted) return found
288 // Fewer chips than sessions that need the person: try a smaller pill for them.
289 for (let chips = busy; chips > (found?.chips ?? -1); chips -= 1) {
290 const tight = fit(chips, true)
291 if (tight !== null) return tight
292 }
293 return found ?? fit(0, true)!
294}
295
296export function bandSvg(list: readonly FleetSession[], mineId: string | undefined, width: number, now: number, isStill = false): { source: string; height: number; width: number } {
297 // Those who need the person lead; the rest keep their order.
298 const busy = list.filter(s => isBusy(s, now)).sort((a, b) => Number(b.state === 'waiting') - Number(a.state === 'waiting'))
299 const idle = list.filter(s => s.state === 'idle' && !isBusy(s, now))
300 const layout = bandLayout(busy.length, idle.length, width, busy.filter(s => s.state === 'waiting').length)
301 const parts = busy.slice(0, layout.chips).map((s, i) => {
302 const x = i * (layout.chipW + CHIP_GAP)
303 const kind = spriteKind(s.state, s.now, s.kind)
304 const textX = x + 42
305 const room = layout.chipW - 42 - 12
306 const agents = s.agents.filter(a => isAgentLive(a, now)).length
307 const here = s.id === mineId ? 30 : 0
308 const meta = agents > 0 ? ` · ${plural(agents, 'agent')}` : ''
309 // Resting between turns with subagents at it: the chip says that, not "waiting for your reply".
310 const doing = s.state === 'idle' && agents > 0 ? `${plural(agents, 'agent')} working` : plain(s.now) + meta
311 const isWait = s.state === 'waiting'
312 const percent = percentOf(progressOf(s.tasks))
313 return [
314 `<rect x="${x + 0.5}" y=".5" width="${layout.chipW - 1}" height="${CHIP_H - 1}" rx="10" fill="var(--panel)" stroke="${isWait ? 'var(--wait)' : 'var(--line)'}"/>`,
315 critter(kind, x + 9, 10, 26, beatOf(now) + i * 370, s.state),
316 `<text x="${textX}" y="19" font-size="12.5" font-weight="600">${escape(fit(nameOf(s), room - here, 12.5, true))}${here ? '<tspan class="faint" font-weight="400" font-size="11"> here</tspan>' : ''}</text>`,
317 `<text x="${textX}" y="35" font-size="11.5" class="${isWait ? 'wait-t' : 'muted'}">${percent !== null ? `<tspan font-weight="600" fill="var(--fg)">${percent}%</tspan> ` : ''}${escape(fit(doing, room - (percent !== null ? 34 : 0), 11.5))}</text>`,
318 ctxOf(s) > 0 ? bar(x + 10, CHIP_H - 6, layout.chipW - 20, ctxOf(s), 2) : '',
319 ].join('')
320 })
321 let used = layout.chips > 0 ? layout.chips * (layout.chipW + CHIP_GAP) : 0
322 if (layout.pillW > 0) {
323 const x = used
324 const sleepers = Math.max(1, Math.min(3, idle.length))
325 parts.push(`<rect x="${x + 0.5}" y=".5" width="${layout.pillW - 1}" height="${CHIP_H - 1}" rx="10" fill="none" stroke="${layout.pillWait ? 'var(--wait)' : 'var(--line)'}" stroke-dasharray="3 3"/>`)
326 if (!layout.isTight) for (let i = sleepers - 1; i >= 0; i -= 1) parts.push(critter(idle.length > 0 ? 'idle' : 'thinking', x + 10 + i * 13, 13, 20, beatOf(now) + 900 + i * 600, idle.length > 0 ? 'idle' : 'working'))
327 const lead = layout.pillWait ? `<tspan class="wait-t">${escape(layout.pillWait)}</tspan>${layout.pillText ? ' · ' : ''}` : ''
328 parts.push(`<text x="${layout.isTight ? x + 12 : x + 14 + 20 + (sleepers - 1) * 13 + 8}" y="28" font-size="11.5" class="muted">${lead}${escape(layout.pillText)}</text>`)
329 used += layout.pillW
330 }
331 const w = Math.max(1, Math.min(width, used))
332 return { source: svg(w, CHIP_H, parts.join(''), isStill), height: CHIP_H, width: w }
333}
334
335// --- The dashboard: every session and its agents, for /fleet. ---
336
337const HEAD_H = 58
338// A row in a wide drawing keeps its context in the metrics column; a narrow one (the side
339// panel) gives it a fourth line.
340const ROW_H = 74
341const ROW_NARROW_H = 92
342const ROW_COMPACT_H = 50
343const AGENT_H = 26
344export const PAD = 20
345const WIDE = 640
346
347// A row never prompted has no third line, and no cache badge to hang under it: it is shorter.
348const COMPACT_RAIL_DROP = 8
349const ROW_BARE_H = 64
350const ROW_NARROW_BARE_H = 76
351
352const baseHeight = (isCompact: boolean, isNarrow: boolean, isBare: boolean): number =>
353 isCompact ? ROW_COMPACT_H : isNarrow ? (isBare ? ROW_NARROW_BARE_H : ROW_NARROW_H) : isBare ? ROW_BARE_H : ROW_H
354
355export function rowHeight(agents: number, loops: number, isCompact: boolean, isNarrow: boolean, isBare = false): number {
356 const lines = agents + (loops > 0 ? 1 : 0)
357 // A compact row's rail starts below its second line, not into it.
358 return baseHeight(isCompact, isNarrow, isBare) + lines * AGENT_H + (lines > 0 ? 6 + (isCompact ? COMPACT_RAIL_DROP : 0) : 0)
359}
360
361// Where a plan limit's colour changes: amber at 60%, red at 85%.
362const NOTCHES = [0.6, 0.85]
363
364// Short radial ticks across the ring's stroke at the notches, clockwise from the top like the arc.
365function notches(cx: number, cy: number, r: number): string {
366 return NOTCHES.map(p => {
367 const a = (p * 360 - 90) * (Math.PI / 180)
368 const at = (d: number) => `${+(cx + d * Math.cos(a)).toFixed(2)} ${+(cy + d * Math.sin(a)).toFixed(2)}`
369 return `<path d="M${at(r - 1.5)}L${at(r + 1.5)}" stroke="var(--fg)" stroke-opacity=".35" stroke-width="1" stroke-linecap="round"/>`
370 }).join('')
371}
372
373function ring(cx: number, cy: number, r: number, fraction: number, hasNotches = false): string {
374 const f = Math.max(0, Math.min(1, fraction))
375 const c = 2 * Math.PI * r
376 const color = f >= 0.85 ? 'var(--err)' : f >= 0.6 ? 'var(--wait)' : 'var(--fg)'
377 return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="var(--fg)" stroke-opacity=".12" stroke-width="3"/>${hasNotches ? notches(cx, cy, r) : ''}<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-opacity="${f >= 0.6 ? 1 : 0.7}" stroke-width="3" stroke-linecap="round" stroke-dasharray="${(c * f).toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 ${cx} ${cy})"/>`
378}
379
380// Where the context stood each time the conversation was compacted: a tick across the bar.
381const ticks = (x: number, y: number, width: number, h: number, compactions: readonly number[] | undefined): string =>
382 (compactions ?? []).map(p => `<rect x="${(x + (width * Math.min(100, p)) / 100 - 0.75).toFixed(1)}" y="${y - 2}" width="1.5" height="${h + 4}" rx=".75" fill="var(--muted)"/>`).join('')
383
384export type DashboardInput = {
385 list: readonly FleetSession[]
386 sum: FleetSummary
387 limits: readonly FleetLimit[]
388 mineId: string | undefined
389 now: number
390 isCompact: boolean
391 isStill?: boolean
392 // At most this many subagent rows a session, the rest folded into "+N more": for a fleet too big to draw whole.
393 agentCap?: number
394 // Draw only the header, or only the row of the session at this place in the list (each on its own).
395 part?: 'header' | number | { main: number } | { agents: number }
396}
397
398// Whether a session's row has subagent or background-loop lines to draw (they can be a picture of
399// their own).
400export const hasAgentRows = (s: FleetSession, now: number): boolean => visibleAgents(s, now).length + recentLoops(s, now).length > 0
401
402// The subagents a session shows: those at work, and those that finished in the last five minutes.
403// One lost (marked running, long silent) is left out: its end never arrived.
404export const visibleAgents = (s: FleetSession, now: number): FleetAgent[] =>
405 s.agents.filter(a => (a.state === 'working' ? isAgentLive(a, now) : now - (a.endedAt ?? now) < 5 * 60_000))
406
407// A worker's progress: a short bar in its role colour and the percent beside it ("~" for a guess).
408const PROGRESS_BAR_W = 32
409function progressMark(x: number, y: number, p: Progress, hue: number, labelSide: 'left' | 'right'): string {
410 const label = progressLabel(p)
411 const fill = Math.max(p.fraction > 0 ? 2 : 0, Math.round(PROGRESS_BAR_W * Math.min(1, p.fraction)))
412 const barX = labelSide === 'left' ? x - PROGRESS_BAR_W : x
413 const text = labelSide === 'left'
414 ? `<text x="${barX - 6}" y="${y + 4}" font-size="10.5" text-anchor="end" class="faint">${label}</text>`
415 : `<text x="${barX + PROGRESS_BAR_W + 6}" y="${y + 4}" font-size="10.5" class="faint">${label}</text>`
416 return `<g class="role" style="--h:${hue}"><rect x="${barX}" y="${y}" width="${PROGRESS_BAR_W}" height="4" rx="2" fill="var(--fg)" fill-opacity=".1"/><rect x="${barX}" y="${y}" width="${fill}" height="4" rx="2" fill="var(--rb)"${p.isEstimate ? ' fill-opacity=".75"' : ''}/></g>${text}`
417}
418
419const progressMarkW = (p: Progress | null): number => (p ? PROGRESS_BAR_W + 6 + measure(progressLabel(p), 10.5) + 10 : 0)
420
421// A flag after a line of text, in its level's colour.
422const flagSpan = (flag: Flag | null): string => (flag ? `<tspan class="${flag.level === 'err' ? 'err-t' : 'wait-t'}"> ${escape(flag.text)}</tspan>` : '')
423
424const flagWidth = (flag: Flag | null, size: number): number => (flag ? measure(` ${flag.text}`, size) : 0)
425
426// The header: what is going on, and the plan limits that bound it.
427export function headerSvg(sum: FleetSummary, limits: readonly FleetLimit[], width: number, now = 0): { parts: string[]; height: number } {
428 const right = width - PAD
429 const parts: string[] = []
430 // On a plan the money isn't spent: it is what the same use would cost on the API.
431 const cost = `$${sum.costUsd.toFixed(2)} ${limits.some(isPlanLimit) ? 'API value' : 'spent'}`
432 const agents = sum.agents > 0 ? `${sum.agents} agent${sum.agents === 1 ? '' : 's'}` : ''
433 const all = [
434 `${sum.sessions} session${sum.sessions === 1 ? '' : 's'}`,
435 sum.working > 0 ? `<tspan fill="var(--work)">${sum.working} working</tspan>` : '0 working',
436 sum.waiting > 0 ? `<tspan fill="var(--wait)">${sum.waiting} need${sum.waiting === 1 ? 's' : ''} you</tspan>` : '',
437 agents,
438 cost,
439 ].filter(Boolean)
440 // The line has to fit the card: the cost goes first, then the agents.
441 const flatOf = (list: string[]) => list.join(' · ').replace(/<[^>]+>/g, '')
442 const fits = (list: string[]) => measure(flatOf(list), 12) <= width - 2 * PAD
443 let counts = all
444 if (!fits(counts)) counts = counts.filter(c => c !== cost)
445 if (!fits(counts)) counts = counts.filter(c => c !== agents)
446 // The limits sit at the right of the title; the counts drop to their own line when they would meet.
447 const ringOf = (limit: FleetLimit, isTimed: boolean, isForecast = true) => {
448 const kind = limitName(limit.kind)
449 // The time left to the reset, when the reading says when that is.
450 const resets = limit.resetsAt !== null ? Date.parse(limit.resetsAt) : NaN
451 const resetName = isTimed && now > 0 && Number.isFinite(resets) && resets > now ? `${kind} · ${resetIn(resets - now)}` : kind
452 // The forecast outranks the reset time: it stays while the reset times drop.
453 const isFull = isForecast && limit.fullInMs !== undefined
454 const name = isFull ? `${kind} · full ~${fullIn(limit.fullInMs!)}` : resetName
455 const text = `${Math.round(limit.percentUsed)}%`
456 return { limit, name, isFull, text, w: 28 + Math.max(measure(text, 12, true), measure(name, 10.5)) + 4 }
457 }
458 // Many limits (a gateway's spend limit beside the plan's two) must not run into the title: the
459 // reset times go, the last ring's first, until the rings fit beside it.
460 const ringsWidth = (list: { w: number }[]) => list.reduce((n, r) => n + r.w, 0) + 14 * Math.max(0, list.length - 1)
461 const room = width - PAD - (PAD + measure('Fleet', 15, true) + 16)
462 let timed = limits.length
463 let rings = limits.map((limit, i) => ringOf(limit, i < timed))
464 while (timed > 0 && ringsWidth(rings) > room) {
465 timed -= 1
466 rings = limits.map((limit, i) => ringOf(limit, i < timed))
467 }
468 // Still too wide with no times: the forecasts go too.
469 if (ringsWidth(rings) > room) rings = limits.map(limit => ringOf(limit, false, false))
470 const ringsW = rings.reduce((n, r) => n + r.w + 14, 0)
471 const isStacked = PAD + measure(flatOf(counts), 12) + 24 + ringsW > width - PAD
472 const headH = isStacked ? HEAD_H + 22 : HEAD_H
473 parts.push(`<text x="${PAD}" y="26" font-size="15" font-weight="650">Fleet</text>`)
474 parts.push(`<text x="${PAD}" y="${isStacked ? 67 : 45}" font-size="12" class="muted">${counts.join('<tspan class="faint"> · </tspan>')}</text>`)
475 let lx = right
476 for (const r of [...rings].reverse()) {
477 lx -= r.w
478 parts.push(ring(lx + 11, 29, 9, r.limit.percentUsed / 100, true))
479 parts.push(`<text x="${lx + 28}" y="27" font-size="12" font-weight="600">${r.text}</text><text x="${lx + 28}" y="40" font-size="10.5" class="${r.isFull ? (limitTone(r.limit) === 'error' ? 'err-t' : 'wait-t') : 'faint'}">${escape(r.name)}</text>`)
480 lx -= 14
481 }
482 parts.push(`<rect x="${PAD}" y="${headH - 1}" width="${width - 2 * PAD}" height="1" fill="var(--line)"/>`)
483
484 return { parts, height: headH }
485}
486
487export function dashboardSvg(input: DashboardInput, width: number): { source: string; height: number } {
488 const { list, sum, limits, mineId, now, isCompact, isStill = false } = input
489 const right = width - PAD
490 const isNarrow = width < WIDE
491 const metricsW = isNarrow ? 0 : 200
492 const parts: string[] = []
493
494 const head = headerSvg(sum, limits, width, now)
495 parts.push(...head.parts)
496 const headH = head.height
497 let y = headH
498 if (list.length === 0) {
499 parts.push(`<text x="${PAD}" y="${y + 30}" font-size="12.5" class="muted">No sessions yet. Sessions started after the fleet mod was installed appear here.</text>`)
500 y += 50
501 }
502
503 // Each session's captain: the main agent's name, here and in the colony.
504 const crews = crewsOf(list)
505 if (input.part === 'header') return { source: svg(width, y, parts.join(''), isStill), height: y }
506
507 // One session's row, drawn from y0 down into `parts`; its height comes back.
508 // `mode`: the whole row, only its own lines ('main'), or only its subagent lines ('agents').
509 const rowOf = (s: FleetSession, index: number, y0: number, parts: string[], hasRule: boolean, slice: 'whole' | 'main' | 'agents' = 'whole'): number => {
510 let y = y0
511 const crew = crews.get(s.id) ?? { name: '' }
512 const visible = visibleAgents(s, now)
513 const agents = slice === 'main' ? [] : visible.slice(0, input.agentCap ?? visible.length)
514 const folded = slice === 'main' ? 0 : visible.length - agents.length
515 const loops = slice === 'main' ? [] : recentLoops(s, now)
516 // What the row says on its third line, and whether there is a line at all (and a badge under the worker).
517 const receipt = receiptText(s.receipt)
518 const asked = taskText(s)
519 const isQuiet0 = s.state === 'idle' || s.state === 'ended'
520 const third = isQuiet0 && receipt ? `Last turn: ${receipt}` : asked ? `Asked: ${asked}` : receipt ? `Last turn: ${receipt}` : ''
521 // (The cache badge sits in the gutter either way and fits.)
522 const isBare = !isCompact && third === ''
523 const h = rowHeight(agents.length + (folded > 0 ? 1 : 0), loops.length, isCompact, isNarrow, isBare)
524 const isQuiet = s.state === 'idle' || s.state === 'ended'
525 const x0 = PAD + 44
526 const textW = width - x0 - PAD - (metricsW ? metricsW + 16 : 0)
527 const kind = spriteKind(s.state, s.now, s.kind)
528 const cache = cacheText(cacheLeft(s, now))
529 const ctx = ctxOf(s)
530 const model = `${modelName(s.model)}${s.effort ? ` ${s.effort}` : ''}`
531 const mode = modeName(s.mode)
532 if (s.state === 'waiting') parts.push(`<rect x="${PAD - 10}" y="${y + 6}" width="3" height="${h - 12}" rx="1.5" fill="var(--wait)"/>`)
533 parts.push(critter(kind, PAD, y + 12, 32, beatOf(now) + index * 410, s.state))
534 if (!isCompact) parts.push(cacheBadge(PAD + 2, y + 57, s, now))
535
536 // Line 1: the name, and how the session stands (on the right when narrow, else in the metrics column).
537 // Narrow, the title needs the room: "here" for "this session", and a small state column.
538 const hereText = isNarrow ? 'here' : 'this session'
539 const here = s.id === mineId ? `<tspan class="faint" font-weight="400" font-size="11.5"> ${hereText}</tspan>` : ''
540 const hereW = s.id === mineId ? (isNarrow ? 40 : 80) : 0
541 const state = stateLine(s, now, isNarrow)
542 const stateClass = s.state === 'waiting' ? 'wait-t' : s.state === 'working' ? '' : 'faint'
543 if (isNarrow) {
544 const stateW = Math.min(measure(state, 11.5), textW * 0.3)
545 const title = fit(nameOf(s), textW - stateW - hereW - 14, 13.5, true)
546 parts.push(`<text x="${x0}" y="${y + 24}" font-size="13.5" font-weight="600" opacity="${isQuiet ? 0.8 : 1}">${escape(title)}${here}</text>`)
547 parts.push(`<text x="${right}" y="${y + 24}" font-size="11.5" text-anchor="end" class="${stateClass}">${escape(fit(state, stateW, 11.5))}</text>`)
548 } else {
549 const sub = [folderOf(s), model, mode].filter(Boolean).join(' · ')
550 const title = fit(nameOf(s), textW * 0.62, 13.5, true)
551 const subX = x0 + measure(title, 13.5, true) + hereW + 12
552 parts.push(`<text x="${x0}" y="${y + 24}" font-size="13.5" font-weight="600" opacity="${isQuiet ? 0.8 : 1}">${escape(title)}${here}</text>`)
553 parts.push(`<text x="${subX}" y="${y + 24}" font-size="11.5" class="faint">${escape(fit(sub, x0 + textW - subX, 11.5))}</text>`)
554 parts.push(`<text x="${right}" y="${y + 24}" font-size="11.5" text-anchor="end" class="${stateClass}">${escape(state)}</text>`)
555 }
556
557 // Line 2: what it is doing, flagged once a tool has run long.
558 const slow = s.state === 'working' ? slowFor(s.toolAt, now) : 0
559 const slowFlag: Flag | null = slow > 0 ? { text: `${formatDuration(slow)}`, level: 'warn' } : null
560 // The captain's name leads: "Nova: Editing svg.ts", "Nova replied: …".
561 const isUnprompted = s.state === 'idle' && !s.last && asked === '' && !receipt
562 const activity = isUnprompted ? 'No prompt yet' : plain(s.state === 'idle' && s.last ? s.last : s.now)
563 const lead = crew.name && !isUnprompted ? `${crew.name}${s.state === 'idle' && s.last ? ' replied' : ''}: ` : ''
564 const leadW = measure(lead, 12.5, true)
565 parts.push(`<text x="${x0}" y="${y + 43}" font-size="12.5" class="${s.state === 'waiting' ? 'wait-t' : isQuiet ? 'muted' : ''}"><tspan font-weight="650">${escape(lead)}</tspan>${escape(fit(activity, textW - leadW - flagWidth(slowFlag, 12.5), 12.5))}${flagSpan(slowFlag)}</text>`)
566
567 // Line 3: the ask while it works; the last turn's receipt once it rests.
568 if (!isCompact && third) parts.push(`<text x="${x0}" y="${y + 61}" font-size="11.5" class="faint">${escape(fit(third, textW, 11.5))}</text>`)
569
570 // Line 4 (narrow): context, with compaction ticks, then model, mode and cost.
571 if (isNarrow && !isCompact) {
572 const barW = 56
573 const by = isBare ? y + 62 : y + 74
574 parts.push(bar(x0, by, barW, ctx))
575 parts.push(ticks(x0, by, barW, 3, s.compactions))
576 const meta = [`${Math.round(ctx * 100)}%`, model, mode, s.costUsd !== null ? `$${s.costUsd.toFixed(2)}` : '', s.compactions?.length ? `compacted ${s.compactions.length}×` : ''].filter(Boolean).join(' · ')
577 parts.push(`<text x="${x0 + barW + 8}" y="${by + 4.5}" font-size="11" class="${ctx >= 0.7 ? 'wait-t' : 'faint'}">${escape(fit(meta, textW - barW - 8, 11))}</text>`)
578 }
579
580 if (metricsW) {
581 const mx = right - metricsW
582 const tokens = totalTokens(s.tokens)
583 const stats = [cache ? `cache ${cache}` : '', s.costUsd !== null ? `$${s.costUsd.toFixed(2)}` : '', tokens > 0 ? `${formatTokens(tokens)} tok` : ''].filter(Boolean).join(' · ')
584 parts.push(bar(mx, y + 36, metricsW, ctx))
585 parts.push(ticks(mx, y + 36, metricsW, 3, s.compactions))
586 const ctxText = `${Math.round(ctx * 100)}% context${s.compactions?.length ? ` · ${s.compactions.length}× compacted` : ''}`
587 parts.push(`<text x="${mx}" y="${y + 56}" font-size="11" class="${ctx >= 0.7 ? 'wait-t' : 'faint'}">${ctxText}</text>`)
588 parts.push(`<text x="${right}" y="${y + 56}" font-size="11" text-anchor="end" class="faint">${escape(fit(stats, metricsW - measure(ctxText, 11) - 10, 11))}</text>`)
589 }
590
591 // Subagents, hung off the session on a thin rail; then the loops no list names, as one line.
592 const agentsFrom = parts.length
593 // Drawn alone, their picture starts at the rail's top.
594 const top = slice === 'agents' ? 10 : y + baseHeight(isCompact, isNarrow, isBare) - 4 + (isCompact ? COMPACT_RAIL_DROP : 0)
595 const aRight = metricsW ? right - metricsW - 16 : right
596 const rail = (i: number, ay: number) => parts.push(`<path d="M${x0 + 2} ${ay - (i === 0 ? 8 : AGENT_H - 6)}V${ay + 9}h10" fill="none" stroke="var(--line)" stroke-width="1.2"/>`)
597 agents.forEach((a, i) => {
598 const ay = top + i * AGENT_H
599 const ax = x0 + 18
600 rail(i, ay)
601 parts.push(critter(spriteKind(a.state, a.now, a.kind), ax - 1, ay - 3, 20, beatOf(now) + index * 410 + (i + 1) * 290, a.state, a.type))
602 // A name given at spawn can run long: it keeps to about a third of the row.
603 const left = fit(a.name || pickName(a.id, []), (right - ax) * 0.3, 12, true)
604 const leftW = measure(left, 12, true)
605 const prog = agentProgress(a)
606 // Narrow, the bar ends the row; wide, it opens the metrics column.
607 const progRight = metricsW ? aRight : right - progressMarkW(prog)
608 if (prog) parts.push(metricsW ? progressMark(right - metricsW, ay + 7, prog, roleOf(a.type).hue, 'right') : progressMark(right, ay + 7, prog, roleOf(a.type).hue, 'left'))
609 parts.push(`<text x="${ax + 24}" y="${ay + 12}" font-size="12" font-weight="600">${escape(left)}</text>`)
610 const flag = agentFlag(a, now)
611 // What it was asked gives way first: in a narrow panel a flag takes its room.
612 // Its role, and in a wide drawing what it was asked; a narrow one has room for the role alone.
613 const desc = isNarrow ? '' : `${a.type} · ${plain(a.description)}`
614 const descRoom = Math.max(0, (progRight - ax - 24 - leftW - 8) * (isNarrow ? 0.45 : 0.4))
615 if (descRoom > 0 && desc !== '') parts.push(`<text x="${ax + 24 + leftW + 8}" y="${ay + 12}" font-size="11.5" class="faint">${escape(fit(desc, descRoom, 11.5))}</text>`)
616 const descW = descRoom > 0 && desc !== '' ? Math.min(measure(desc, 11.5), descRoom) + 10 : 0
617 const nowX = ax + 24 + leftW + 8 + descW
618 const doing = a.state === 'done' ? 'Done' : plain(a.now)
619 parts.push(`<text x="${nowX}" y="${ay + 12}" font-size="11.5" class="${a.state === 'working' ? 'muted' : 'faint'}">${escape(fit(doing, progRight - nowX - flagWidth(flag, 11.5), 11.5))}${flagSpan(flag)}</text>`)
620 if (metricsW) {
621 const tokens = totalTokens(a.tokens)
622 const stats = [modelName(a.model), formatDuration((a.endedAt ?? now) - a.startedAt), tokens > 0 ? `${formatTokens(tokens)} tok` : '', a.contextTokens > 0 ? `ctx ${Math.round((a.contextTokens / (a.contextWindow || 1)) * 100)}%` : ''].filter(Boolean).join(' · ')
623 parts.push(`<text x="${right}" y="${ay + 12}" font-size="11" text-anchor="end" class="faint">${escape(fit(stats, metricsW - progressMarkW(prog), 11))}</text>`)
624 }
625 })
626 if (folded > 0) {
627 const ay = top + agents.length * AGENT_H
628 rail(agents.length, ay)
629 parts.push(`<text x="${x0 + 18}" y="${ay + 12}" font-size="11.5" class="faint">+${folded} more agent${folded === 1 ? '' : 's'}</text>`)
630 }
631 if (loops.length > 0) {
632 const i = agents.length + (folded > 0 ? 1 : 0)
633 const ay = top + i * AGENT_H
634 const ax = x0 + 18
635 rail(i, ay)
636 const isLive = loops.some(l => now - l.lastAt < 30_000)
637 parts.push(critter(isLive ? 'thinking' : 'idle', ax - 1, ay - 3, 20, beatOf(now) + index * 410 + (i + 1) * 290, isLive ? 'working' : 'idle', 'background'))
638 const name = `${loops.length} background loop${loops.length === 1 ? '' : 's'}`
639 const nameW = measure(name, 12, true)
640 parts.push(`<text x="${ax + 24}" y="${ay + 12}" font-size="12" font-weight="600" class="muted">${name}</text>`)
641 const models = [...new Set(loops.map(l => modelName(l.model)))].join(', ')
642 const tokens = loops.reduce((n, l) => n + l.tokens, 0)
643 const steps = loops.reduce((n, l) => n + l.steps, 0)
644 const what = [models, `${steps} step${steps === 1 ? '' : 's'}`, tokens > 0 ? `${formatTokens(tokens)} tok` : ''].filter(Boolean).join(' · ')
645 parts.push(`<text x="${ax + 24 + nameW + 8}" y="${ay + 12}" font-size="11.5" class="faint">${escape(fit(`${what} · workflows and forks`, aRight - ax - 24 - nameW - 8, 11.5))}</text>`)
646 }
647
648 const agentsTo = parts.length
649 if (slice === 'agents') {
650 parts.splice(0, parts.length, ...parts.slice(agentsFrom, agentsTo))
651 return (agents.length + (folded > 0 ? 1 : 0) + (loops.length > 0 ? 1 : 0)) * AGENT_H + 12
652 }
653 // The main agent's progress runs along the row's foot, in its own colour; a resting row keeps a plain rule.
654 const mainProgress = s.state === 'working' || s.state === 'waiting' ? sessionProgress(s) : null
655 const footW = width - x0 - PAD
656 y += h
657 if (mainProgress) {
658 parts.push(`<rect x="${x0}" y="${y - 2}" width="${footW}" height="2" rx="1" fill="var(--fg)" fill-opacity=".08"/>`)
659 parts.push(`<g class="role" style="--h:${roleOf().hue}"><rect x="${x0}" y="${y - 2}" width="${Math.max(2, Math.round(footW * mainProgress.fraction))}" height="2" rx="1" fill="var(--rb)"${mainProgress.isEstimate ? ' fill-opacity=".75"' : ''}/></g>`)
660 } else if (hasRule && index < list.length - 1) parts.push(`<rect x="${x0}" y="${y - 1}" width="${footW}" height="1" fill="var(--line)"/>`)
661 return h
662 }
663
664 if (typeof input.part === 'number' || (input.part && typeof input.part === 'object')) {
665 const rows: string[] = []
666 const at = typeof input.part === 'number' ? input.part : 'main' in input.part ? input.part.main : input.part.agents
667 const mode = typeof input.part === 'number' ? 'whole' : 'main' in input.part ? 'main' : 'agents'
668 const row = list[at]
669 const h = row ? rowOf(row, at, 0, rows, false, mode) : 0
670 return { source: svg(width, h, rows.join(''), isStill), height: h }
671 }
672 list.forEach((s, index) => {
673 y += rowOf(s, index, y, parts, true)
674 })
675
676 const height = y + 10
677 return { source: svg(width, height, parts.join(''), isStill), height }
678}
679hooks/colony-view-19.tsx 19 lines1import type { ClientModule } from 'claude-code'
2
3import type { ColonyScene } from './colony'
4import { colonySvg } from './colony'
5
6// The colony, drawn in the desktop page itself. The panel is drawn afresh on every redraw, and an
7// image drawn afresh restarts its animations; this region is kept across those redraws, so the
8// village draws again only when its scene changes, timed by the page's own clock.
9
10// The environment's element factory: the table hands out no Svg, but the page draws one.
11declare const h: (type: string, props: Record<string, unknown>) => ReturnType<ClientModule>
12
13const ColonyView: ClientModule<ColonyScene> = scene => {
14 const drawing = colonySvg(scene, Date.now())
15 return h('Svg', { source: drawing.source, alt: drawing.alt, width: scene.width, height: drawing.height })
16}
17
18export default ColonyView
19types/index.d.ts 122 lines1export type FleetState = 'working' | 'waiting' | 'idle' | 'ended'
2
3export type FleetTokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
4
5export type FleetAgent = {
6 id: string
7 // What to call it: the name it was spawned with, else one picked for it (unique in its session).
8 name?: string
9 type: string
10 description: string
11 model: string
12 state: 'working' | 'done' | 'failed'
13 now: string
14 // What kind of work `now` is (reading, editing, running...), for the worker drawn.
15 kind?: string
16 // Steps done of planned, as the subagent reports them (savvy-progress's step tool).
17 progress?: FleetProgress | null
18 startedAt: number
19 endedAt: number | null
20 // When it last did anything (a tool call or a model reply): a running agent quiet too long is flagged.
21 lastAt?: number
22 // When its current tool call started; null between calls.
23 toolAt?: number | null
24 // A reply cut off at the output-token limit.
25 hitMax?: boolean
26 tokens: FleetTokens
27 contextTokens: number
28 contextWindow: number
29 steps: number
30 tools: number
31}
32
33export type FleetProgress = { done: number; total: number }
34
35// What one finished turn of the main agent did.
36export type FleetReceipt = { ms: number; edits: number; errors: number; agents: number; costUsd: number | null; endedAt: number }
37
38// The running tally of the turn in progress, which becomes its receipt.
39export type FleetTally = { edits: number; errors: number; agents: number; costStart: number | null }
40
41// A model loop no agent list names: a workflow's agents, and the engine's or a plugin's forks.
42export type FleetLoop = { id: string; model: string; steps: number; tokens: number; startedAt: number; lastAt: number }
43
44export type FleetLimit = {
45 kind: string
46 percentUsed: number
47 resetsAt: string | null
48 // The forecast: ms until the limit fills at the current pace. Set for drawing only, never written to a heartbeat.
49 fullInMs?: number
50}
51
52// One session's heartbeat, as its own copy of the mod writes it to ~/.claude/fleet/<id>.json.
53export type FleetSession = {
54 v: 1
55 id: string
56 project: string
57 // The session's title as the app shows it, when it has one.
58 title?: string
59 cwd: string
60 surface: string
61 model: string
62 effort: string | null
63 state: FleetState
64 task: string
65 now: string
66 kind?: string
67 // The session's task list, by task id: its status. Progress is the completed share.
68 tasks?: Record<string, string>
69 last: string
70 turnStartedAt: number | null
71 // When the main agent's current tool call started; null between calls.
72 toolAt?: number | null
73 // The desktop app's id for this session (CLAUDE_CODE_HOST_SESSION_ID): claude://claude.ai/epitaxy/<id> opens it.
74 // Terminal sessions have none.
75 hostId?: string
76 // The permission mode (default, acceptEdits, plan, auto, bypassPermissions, dontAsk).
77 mode?: string
78 // Context used, in percent, each time the conversation was compacted.
79 compactions?: number[]
80 tally?: FleetTally | null
81 receipt?: FleetReceipt | null
82 loops?: FleetLoop[]
83 // The side panel opened by itself at this session's first subagent; the person closed it. Kept in
84 // the record so a reload of the mod remembers both.
85 panelOpenedOnce?: boolean
86 panelClosed?: boolean
87 // A reading of this session once held a plan limit: it is on a subscription, so the fleet's plan readings apply to it.
88 hasPlan?: boolean
89 // When the person last sent a prompt: the panel lists the most recent first.
90 promptAt?: number
91 // When the main agent last heard back from the model: its prompt cache lives a TTL past this.
92 cacheAt?: number | null
93 idleSince?: number | null
94 // When the session began waiting on the person; null once it stops.
95 waitingSince?: number | null
96 turns: number
97 toolsThisTurn: number
98 toolsTotal: number
99 contextTokens: number
100 contextWindow: number
101 contextPercent: number | null
102 costUsd: number | null
103 tokens: FleetTokens
104 limits: FleetLimit[]
105 agents: FleetAgent[]
106 startedAt: number
107 updatedAt: number
108}
109
110export type FleetView = { isCompact: boolean; isStill: boolean; showEnded: boolean; showBand: boolean; showColony?: boolean; showAlerts?: boolean }
111
112declare module 'claude-code' {
113 interface PluginState {
114 fleet: {
115 me: FleetSession | null
116 sessions: FleetSession[]
117 frame: number
118 view: FleetView
119 }
120 }
121}
122