SLOPSHOPPER

fleet

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…

panebandrowsguardcommandtoast
v0.24.1MITupdated 2026-10-19michaelt604/fleet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fleet
│ ┃ Fleet ✕ › fix the failing auth test and add an audit log call │ ┃ Fleet 0 sessions none working $0.00 spent │ ┃ ⏺ Read(src/auth.ts) │ ┃ No sessions yet. Sessions started after the ⎿ Read 6 lines │ ┃ fleet mod was installed appear here. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Compact ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /fleet │ ⎿ fleet: Fleet is open in the side panel. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Fleet
Fleet 0 sessions none working $0.00 spent No sessions yet. Sessions started after the fleet mod was installed appear here. Compact
README

fleet

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.

Install

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.

Usage

  • /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").
  • Each option replies with what it changed and how to undo it. An option it doesn't know gets a reply listing the ones it does.
  • Status line: ⚑ 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 row

  • Line 1: the session's name, and how it stands (working, needs you, idle) with its progress. In a narrow panel the progress leaves this line (the bar along the foot shows it) and "this session" becomes "here", so the name keeps its room.
  • Line 2: its main agent, by name, and what it is doing now: "Hugo: Editing svg.ts". A tool call running past 30 seconds shows its time in amber.
  • Line 3: what you asked, or once it rests, the last turn's receipt: time, edits, agents, errors, cost.
  • Line 4 (narrow panel): context used, with a tick wherever the conversation was compacted, then model, permission mode and cost.
  • Under the worker: the prompt-cache time left. It's an hour after the last reply on a plan, and five minutes on an API key or past a plan limit. "cold" means the next prompt re-sends the whole context.
  • The thin line along the foot: the main agent's progress. It comes from the task list when there is one; otherwise it's a guess from this turn's tool calls, shown with "~".
  • Subagents: each has its own name (the one it was spawned with, else one picked for it), its role, what it's doing, and a progress bar. Its own step reports make the bar exact; otherwise it's a "~" guess that never shows done until the agent finishes. Flags: "slow 45s" (a tool running long), "quiet 3m" (no activity for 2 minutes), "hit max tokens".
  • Background loops: model loops no agent list names, such as a workflow's agents or forks, folded into one line.

Workers and the colony

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:

HatRole
crowneach session's main agent
propeller beaniehaiku-quick
caphaiku-worker
ranger hatExplore
hard hatsonnet-worker
mortarboardPlan
wizard hatopus-deep
top hatopus-worker
beaniegeneral-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".

BuildingSignTask
workshophammerDevelop: writing and changing code
librarybookResearch: investigating, reading up, exploring the codebase
watchtowerlensReview: code review, critique, audits
forgeflaskTest: running tests, builds, lints, deploys
town hallscrollPlan: plans and designs, delegating, and waiting on you (with a "!")
minebugDebug: digging into bugs and failures
  • Subagents take their task from what they were asked and their role ("Review the demo" goes to the watchtower; Explore agents always research, Plan agents always plan). They keep it, except that running tests sends them to the forge.
  • Main agents start from your prompt, then follow what they're doing: editing takes them to the workshop, web research to the library, commands to the forge. Between tool calls they stay where they are.
  • Changes hold before anyone moves: a new task must hold for 10 seconds, getting back to work takes 3, and going off to play waits for 15 seconds of rest. A quick command or a short turn doesn't send anyone off and back. Waiting on you goes to the hall at once.
  • Background subagents keep working while their session rests between turns, and count in the status line and the band. One silent for 20 minutes (an hour with a tool running) is taken as lost: its end never arrived, so it is dropped.
  • "Needs you" shows when a permission dialog does, a subagent's too ("Needs your OK (Hugo): …"), and when the session asks you a question or offers a plan to approve. While any subagent's dialog is open the session shows as needing you, whatever else it does; it goes back when the dialog's call ends. In auto mode, calls the classifier settles never show it.
  • Idle workers each have a pastime of their own. Round the fire they sip from a mug, stroll a little loop, juggle, dance, strum a lute, read a book, fly a kite or feed the birds. Some go further: to fish at the pond, water the garden, or pet the village cat. Each of those has room for two; anyone who finds it full stays by the fire and does something else there. Finished subagents sleep by the tents.
  • Motion is slow and smooth. Walks follow the dirt roads through the plaza at an unhurried pace, setting off and arriving gently, with a small step on the way. A worker called away mid-walk or mid-stroll sets off from exactly where it is. Work is a gentle bob, and a hammer is raised slowly and brought down quickly.

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.

Notes

  • Agent types: the hats and pacing know the built-in agents (Explore, Plan, general-purpose) and a few custom ones (haiku-quick, haiku-worker, sonnet-worker, opus-worker, opus-deep). Any other agent type still gets a steady hat and colour of its own.
  • Exact subagent progress: a subagent's bar is exact when it reports steps through a step tool from an MCP server named savvy-progress (mcp__savvy-progress__step, with done and total). Without one, bars are "~" guesses.
  • Files it writes: one heartbeat per session in ~/.claude/fleet/. It reads the desktop app's own session records only to hide archived sessions.

Develop

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

  • Validate: claude plugin validate . checks the manifest, the marketplace file and the hooks module the way the engine reads them.
  • Tests: claude plugin test . runs tests/*.test.ts(x) against a fake engine.
  • Type-check: once the engine has loaded the mod it writes .claude-plugin/types/ (ignored by git); then tsc -p ..
  • Layout: 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.
  • Releasing: bump 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.

License

MIT

Source 8 files
hooks/register.tsx 981 lines
1import { 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}
981
hooks/describe.ts 595 lines
1import 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'
595
hooks/colony-scene.ts 322 lines
1import 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}
322
hooks/draw.tsx 314 lines
1import 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}
314
hooks/colony.ts 1152 lines
1import 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}
1152
hooks/svg.ts 679 lines
1import 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, '&amp;')
20    .replace(/</g, '&lt;')
21    .replace(/>/g, '&gt;')
22    .replace(/"/g, '&quot;')
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}
679
hooks/colony-view-19.tsx 19 lines
1import 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
19
types/index.d.ts 122 lines
1export 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