Animated constellation of the main agent and every subagent: status, context window and run time

A Claude Code mod that shows the main agent and every subagent live: status, context window fill and run time.
/agents-eagle-eye opens an animated live view in your browser (one page per session) and a small pane in Claude Code./agents-eagle-eye demo toggles a sample cast of agents./agents-eagle-eye 3d or /agents-eagle-eye 2d picks the view. The pane has a View button that does the same./agents-eagle-eye close stops the live updates.Finished agents (completed, stopped, offline) are drawn small, dim and wordless, behind the live ones.
The browser page has two views of the same agents, switched with the 2D | 3D tab at the top right:
Which view you see at load, in order of priority:
#3d or #2d (for example index.html#3d). Deep links to either view work./agents-eagle-eye 3d|2d or the View button).The sync is one way. The pane and the slash command tell the browser page which view to show, but a tab you click in the browser does not go back to the pane, so the pane's View button can say 2D while the tab shows 3D.
If 3D cannot start (no WebGL2, the bundle fails to load, or the graphics context is lost and does not come back), the page shows 2D and a short notice. That fallback is not remembered, so the next load tries 3D again.
?renderer=2d forces the 2D view and disables 3D.?axes=1 draws each planet's spin axis, spin direction and precession.?freeze=1 freezes the 3D clock (the camera still moves), for screenshots.?gov=0 turns off the 3D quality governor, for measuring.web/three/ is a small layered engine. The shell (web/bridge.ts) loads eagle-eye-3d.js on demand and gives it the live state once a second. The engine owns the renderer, camera rig, clocks and quality governor, and runs a list of layers (background, stars, orb, planets, links, packets, choreography, labels, and so on). Each layer is one file in web/three/layers/ that reads and writes a shared world model, so layers do not import each other. See web/three/README-dev.md for the layer contract, the debug API and the file map.
hooks/register.tsx: tracking, the commands and the pane.hooks/mini.ts: the pane's animated SVGs (desktop and VS Code): 2D and the pseudo-3D variant.hooks/model.ts: formatting, icon and status helpers.web/agent-network.ts: the browser design (canvas).web/bridge.ts: feeds live agents into the design and switches 2D | 3D. web/eagle-eye.js is its bundle.web/three/: the 3D view (engine, layers). web/eagle-eye-3d.js is its bundle.tests/: run with claude plugin test .Load it from a folder:
claude --plugin-dir /path/to/agents-eagle-eye
or set CLAUDE_CODE_PLUGIN_DIRS to its path in the env block of ~/.claude/settings.json.
Both bundles (web/eagle-eye.js for 2D, web/eagle-eye-3d.js for 3D) are built from the repo root:
bun install
bun run build # build:2d + build:3d
bun run typecheck # tsc -p tsconfig.web.json
bun test # web/tests
bun run check:bundle # web/eagle-eye-3d.js: ASCII only, no import(), fetch(), eval(), workers, under 1 MB
bun run verify # typecheck + test + build + check:bundle
bun run build:2d or bun run build:3d builds one of them. node scripts/dev-server.mjs (set PORT, default 8765) serves web/ with a mock state for local work, for example http://127.0.0.1:8765/index.html#3d.
The page files are copied into the live folder once per mod load, so after a rebuild you must reload the mod (or restart the session) for the browser to get the new bundles.
hooks/register.tsx 433 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, StateDollar } from 'claude-code'
3
4import type { AgentRec, AgentStatusWord, MainRec } from '../types'
5import { renderMini, renderMini3D } from './mini'
6import {
7 STATUS_COLOR,
8 STATUS_LABEL,
9 demoAgents,
10 demoMain,
11 designStatus,
12 displayName,
13 formatDuration,
14 formatTokens,
15 iconFor,
16 isLive,
17 prettyModel,
18} from './model'
19
20const PANE = 'agents-eagle-eye'
21const COMMAND = 'agents-eagle-eye'
22/** The page and its scripts, shipped in web/, copied beside the live state. */
23const WEB_FILES = ['index.html', 'eagle-eye.js', 'eagle-eye-3d.js']
24
25const agentsAtom = atom({ plugin: 'agents-eagle-eye', key: 'agents' } as const, {} as Record<string, AgentRec>)
26const mainAtom = atom(
27 { plugin: 'agents-eagle-eye', key: 'main' } as const,
28 {
29 status: 'idle',
30 startedAt: 0,
31 contextTokens: 0,
32 window: 0,
33 outputTokens: 0,
34 } as MainRec,
35)
36const demoAtom = atom({ plugin: 'agents-eagle-eye', key: 'demo' } as const, 0)
37const viewAtom = atom({ plugin: 'agents-eagle-eye', key: 'view' } as const, '2d' as '2d' | '3d')
38const viewSeqAtom = atom({ plugin: 'agents-eagle-eye', key: 'viewSeq' } as const, 0)
39
40/** Persists a view choice and bumps the counter that tells the page it was explicit. */
41const setView = async ($: StateDollar, view: '2d' | '3d') => {
42 await update($, viewAtom, () => view)
43 await update($, viewSeqAtom, n => (n ?? 0) + 1)
44}
45
46let isOpen = false
47/** Whether the browser view is on: then the state file is rewritten each second. */
48let isViewing = false
49let copiedTo: string | undefined
50
51const OPEN_ARGS = { id: PANE, title: 'Eagle Eye', rows: 18, columns: 72 }
52
53/** The agents and main record to draw: the real ones, or the demo cast. */
54const current = async ($: StateDollar) => {
55 const [agents, main, demo] = await Promise.all([read($, agentsAtom), read($, mainAtom), read($, demoAtom)])
56 const now = Date.now()
57 if (demo > 0) return { agents: demoAgents(demo, now), main: demoMain(demo, now, main.model), now }
58 return { agents: Object.values(agents), main, now }
59}
60
61const patchAgent = async ($: StateDollar, id: string, fn: (a: AgentRec) => AgentRec, now: number) =>
62 update($, agentsAtom, all => {
63 const prev = all?.[id] ?? {
64 id,
65 name: '',
66 type: 'agent',
67 status: 'running' as AgentStatusWord,
68 startedAt: now,
69 contextTokens: 0,
70 outputTokens: 0,
71 toolCalls: 0,
72 }
73 return { ...(all ?? {}), [id]: fn(prev) }
74 }).catch(() => undefined)
75
76const patchMain = async ($: StateDollar, fn: (m: MainRec) => MainRec) => {
77 try {
78 await update($, mainAtom, m => fn(m))
79 } catch {
80 // Observing only: a lost update is redrawn by the next one.
81 }
82}
83
84/** Tokens a request was answered over plus what it wrote: what its next request re-sends. */
85const contextOf = (u: {
86 input_tokens: number
87 cache_read_input_tokens: number
88 cache_creation_input_tokens: number
89 output_tokens: number
90}) => u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
91
92const fromListStatus = (s: string): AgentStatusWord | undefined =>
93 ({
94 pending: 'waiting',
95 running: 'running',
96 waiting: 'waiting',
97 idle: 'idle',
98 completed: 'completed',
99 failed: 'failed',
100 killed: 'stopped',
101 })[s] as AgentStatusWord | undefined
102
103/**
104 * Where live pages live: outside the plugin's folder, so the state files
105 * rewritten each second never reload the mod (~/.claude/agents-eagle-eye-live).
106 */
107const liveBase = (pluginRoot: string): string => {
108 const parts = pluginRoot.replace(/\\/g, '/').split('/')
109 const at = parts.lastIndexOf('.claude')
110 const base = at > 0 ? parts.slice(0, at + 1) : parts.slice(0, -1)
111 return `${base.join('/')}/agents-eagle-eye-live`
112}
113
114/**
115 * This session's own folder under it, by session id: each session writes its
116 * own page and state, so two sessions never overwrite each other's view.
117 */
118const liveDir = async ($: EngineInterface): Promise<string> => `${liveBase($.plugin.root)}/${(await $.session.id()).replace(/[^\w-]/g, '')}`
119
120const pageUrl = (dir: string) => (dir.startsWith('/') ? `file://${dir}/index.html` : `file:///${dir}/index.html`)
121
122type Files = { fs: { read: (path: string) => Promise<unknown>; write: (path: string, text: string) => Promise<void> } }
123
124/** Copies the page into the live folder, once per load so a rebuilt page reaches it. */
125const ensurePage = async ($: Files, root: string, dir: string) => {
126 if (copiedTo === dir) return
127 for (const name of WEB_FILES) {
128 // A file that cannot be read is skipped, so a missing 3D bundle never breaks the mod.
129 let text: unknown
130 try {
131 text = await $.fs.read(`${root}/web/${name}`)
132 } catch {
133 continue
134 }
135 if (text === undefined || text === null) continue
136 await $.fs.write(`${dir}/${name}`, String(text))
137 }
138 copiedTo = dir
139}
140
141/** The project folder's name, so a tab says whose session it shows. */
142const projectName = async ($: EngineInterface) => {
143 const cwd = (await $.session.cwd()).replace(/\\/g, '/').replace(/\/+$/, '')
144 return cwd.slice(cwd.lastIndexOf('/') + 1) || cwd
145}
146
147/** What the page draws: the design's agent records, as of now. */
148const liveState = async ($: EngineInterface) => {
149 const { agents, main, now } = await current($)
150 const demo = (await read($, demoAtom)) > 0
151 const [view, viewSeq] = await Promise.all([read($, viewAtom), read($, viewSeqAtom)])
152 return {
153 view,
154 viewSeq,
155 model: prettyModel(main.model).replace(/^Claude /, ''),
156 session: `${await projectName($)} · ${(await $.session.id()).slice(0, 8)}`,
157 demo,
158 updatedAt: now,
159 agents: agents.map(a => ({
160 id: a.id,
161 name: displayName(a),
162 icon: iconFor(a.type, a.name),
163 status: designStatus(a.status),
164 tokens: a.contextTokens,
165 seconds: Math.max(0, ((a.endedAt ?? now) - a.startedAt) / 1000),
166 })),
167 }
168}
169
170const writeState = async ($: EngineInterface, dir: string) =>
171 $.fs.write(`${dir}/state.js`, `window.__eagleEye && window.__eagleEye(${JSON.stringify(await liveState($))})\n`)
172
173export const register: Register = on => {
174 on('session.start', async ($, e, next) => {
175 await $.command.register({
176 name: COMMAND,
177 description: 'Eagle Eye: watch the main agent and every subagent live (args: demo | 3d | 2d | close)',
178 argumentHint: '[demo|3d|2d|close]',
179 })
180
181 const usage = await $.session.usage()
182 await patchMain($, m => ({
183 ...m,
184 startedAt: m.startedAt || usage.startedAt,
185 window: usage.context.window || m.window || 200_000,
186 contextTokens: usage.context.tokens ?? m.contextTokens,
187 }))
188
189 // Once a second: reconcile with the engine's agent list, rewrite the live
190 // view's state, and redraw the pane so its timers tick.
191 $.clock.every(1000, () => {
192 void (async () => {
193 const u = await $.session.usage()
194 await patchMain($, m => ({
195 ...m,
196 window: u.context.window || m.window,
197 contextTokens: u.context.tokens ?? m.contextTokens,
198 }))
199 const listed = await $.agent.list()
200 const now = Date.now()
201 for (const info of listed) {
202 const status = fromListStatus(info.status)
203 await patchAgent(
204 $,
205 info.id,
206 a => ({
207 ...a,
208 name: a.name || info.name || info.description,
209 type: a.type === 'agent' ? info.type : a.type,
210 parentId: a.parentId ?? info.parentId,
211 // Ours is finer while it lives (thinking vs running); the list wins once it ends.
212 status: status && (!isLive(status) || !isLive(a.status) || status !== 'running') ? status : a.status,
213 endedAt: status && !isLive(status) ? (a.endedAt ?? now) : a.endedAt,
214 }),
215 now,
216 )
217 }
218 if (isViewing) {
219 const dir = await liveDir($)
220 await ensurePage($, $.plugin.root, dir)
221 await writeState($, dir)
222 }
223 if (isOpen) $.ui.invalidate('ui.render')
224 })().catch(() => undefined)
225 })
226
227 return next(e)
228 })
229
230 on('command.run', { command: COMMAND }, async ($, e) => {
231 const arg = e.args.trim().toLowerCase()
232 if (arg === 'close') {
233 await $.ui.close({ id: PANE })
234 isOpen = false
235 isViewing = false
236 return { text: 'Eagle Eye closed. The browser tab stops updating.' }
237 }
238 if (arg === 'demo') {
239 const isDemo = (await read($, demoAtom)) === 0
240 await update($, demoAtom, () => (isDemo ? Date.now() : 0))
241 }
242 if (arg === '3d' || arg === '2d') await setView($, arg)
243 const dir = await liveDir($)
244 await ensurePage($, $.plugin.root, dir)
245 await writeState($, dir)
246 // Open the browser only when the view was off; a demo toggle reaches an open tab by itself.
247 if (!isViewing) await openInBrowser($, dir)
248 isViewing = true
249 const opened = await $.ui.open(OPEN_ARGS)
250 isOpen = opened.isPlaced
251 const demo = (await read($, demoAtom)) > 0
252 return {
253 text:
254 arg === '3d' || arg === '2d'
255 ? `Eagle Eye view: ${arg.toUpperCase()}.`
256 : arg === 'demo'
257 ? `Eagle Eye demo ${demo ? 'on: the sample cast' : 'off: your real agents'}.`
258 : `Eagle Eye live view opened in your browser: ${pageUrl(dir)}`,
259 }
260 })
261
262 on('ui.close', { id: PANE }, ($, e, next) => {
263 isOpen = false
264 return next(e)
265 }).catch(($, e, next) => (next.called ? undefined : next(e)))
266
267 // A subagent starts: record who it is and who spawned it.
268 on('agent.spawn', async ($, e, next) => {
269 const started = await next(e)
270 if ('agentId' in started && started.agentId) {
271 const now = Date.now()
272 await patchAgent(
273 $,
274 started.agentId,
275 a => ({
276 ...a,
277 name: e.name || e.description || a.name,
278 type: e.subagentType || a.type,
279 model: started.model,
280 parentId: e.parentAgentId,
281 status: a.status === 'thinking' ? 'thinking' : 'running',
282 startedAt: Math.min(a.startedAt, now),
283 }),
284 now,
285 )
286 }
287 return started
288 }).catch(($, e, next) => (next.called ? Promise.reject(new Error('agents-eagle-eye: agent.spawn observer failed')) : next(e)))
289
290 // Each model request: thinking while it streams, then its context size.
291 on('turn.step', async function* ($, e, next) {
292 const now = Date.now()
293 if (e.agentId) {
294 await patchAgent($, e.agentId, a => ({ ...a, status: 'thinking', endedAt: undefined, model: a.model ?? e.model }), now)
295 } else {
296 await patchMain($, m => ({ ...m, status: 'thinking', model: e.model }))
297 }
298 const res = yield* next(e)
299 const usage = res.usage
300 const status: AgentStatusWord = res.toolUses.length > 0 ? 'running' : 'thinking'
301 if (e.agentId) {
302 await patchAgent(
303 $,
304 e.agentId,
305 a => ({
306 ...a,
307 status: isLive(a.status) ? status : a.status,
308 contextTokens: usage ? contextOf(usage) : a.contextTokens,
309 outputTokens: a.outputTokens + (usage?.output_tokens ?? 0),
310 model: usage?.model ?? a.model,
311 }),
312 now,
313 )
314 } else {
315 await patchMain($, m => ({
316 ...m,
317 status,
318 model: usage?.model ?? m.model,
319 contextTokens: usage ? contextOf(usage) : m.contextTokens,
320 outputTokens: m.outputTokens + (usage?.output_tokens ?? 0),
321 }))
322 }
323 return res
324 })
325
326 // Each tool call: running, and which tool.
327 on('tool.call', async ($, e, next) => {
328 const now = Date.now()
329 if (e.agentId) {
330 await patchAgent($, e.agentId, a => ({ ...a, status: 'running', toolCalls: a.toolCalls + 1, lastTool: e.tool }), now)
331 } else {
332 await patchMain($, m => ({ ...m, status: 'running' }))
333 }
334 return next(e)
335 }).catch(($, e, next) => (next.called ? Promise.reject(new Error('agents-eagle-eye: tool.call observer failed')) : next(e)))
336
337 on('turn.start', async ($, e, next) => {
338 await patchMain($, m => ({ ...m, status: 'thinking' }))
339 return next(e)
340 })
341
342 // A turn ends: a subagent's run is over; the main agent goes idle.
343 on('turn.complete', async ($, e, next) => {
344 const now = Date.now()
345 const status: AgentStatusWord = e.reason === 'answer' ? 'completed' : e.reason === 'aborted' ? 'stopped' : 'failed'
346 if (e.agentId) {
347 await patchAgent($, e.agentId, a => ({ ...a, status, endedAt: now }), now)
348 } else {
349 await patchMain($, m => ({ ...m, status: e.reason === 'answer' ? 'idle' : status === 'failed' ? 'failed' : 'idle' }))
350 }
351 return next(e)
352 })
353
354 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
355 isOpen = true
356 const { Box, Text, Button } = $.ui.resolve(e)
357 // Only the remote surfaces have an Svg element; the terminal's table does not.
358 const Svg = e.surface === 'terminal' ? undefined : $.ui.resolve(e).Svg
359 // A breadcrumb for debugging an empty pane: what the last draw was and whether it threw.
360 const note = (line: string) =>
361 void liveDir($)
362 .then(dir => $.fs.write(`${dir}/render.log`, `${new Date().toISOString()} ${line}
363`))
364 .catch(() => undefined)
365 try {
366 const { agents, main, now } = await current($)
367 const view = await read($, viewAtom)
368 const room = Math.max(3, (e.props.scroll?.bodyRows ?? 24) - (e.surface === 'terminal' ? 3 : 22))
369 const rows = [...agents].sort((a, b) => Number(isLive(b.status)) - Number(isLive(a.status))).slice(0, room)
370 const live = agents.filter(a => isLive(a.status)).length
371 note(`ok surface=${e.surface} agents=${agents.length} bodyColumns=${e.props.bodyColumns} bodyRows=${e.props.scroll?.bodyRows}`)
372 return (
373 <Box flexDirection="column">
374 <Text bold>{`Eagle Eye · ${prettyModel(main.model).replace(/^Claude /, '')} · ${live} live / ${agents.length} agents`}</Text>
375 {Svg && (
376 <Svg
377 source={view === '3d' ? renderMini3D(main, agents, now) : renderMini(main, agents, now)}
378 alt={`Orchestrator with ${agents.length} agents, ${live} live`}
379 isInteractive
380 />
381 )}
382 <Button
383 key="view"
384 plain
385 onPress={() => {
386 // Persist on press, never during render; the next redraw shows it.
387 void setView($, view === '3d' ? '2d' : '3d')
388 .then(() => $.ui.invalidate('ui.render'))
389 .catch(() => undefined)
390 }}
391 >
392 {view === '3d' ? 'View: 3D · switch to 2D' : 'View: 2D · switch to 3D'}
393 </Button>
394 {rows.map(a => (
395 <Box flexDirection="row" gap={1}>
396 <Text color={STATUS_COLOR[a.status]}>{`● ${STATUS_LABEL[a.status].padEnd(9)}`}</Text>
397 <Text bold wrap="truncate">
398 {displayName(a).padEnd(18)}
399 </Text>
400 <Text dimColor wrap="truncate">
401 {`${formatTokens(a.contextTokens).padStart(5)} · ${formatDuration((a.endedAt ?? now) - a.startedAt)}${a.lastTool ? ` · ${a.lastTool}` : ''}`}
402 </Text>
403 </Box>
404 ))}
405 {agents.length === 0 && <Text dimColor>{`No subagents yet. /${COMMAND} demo shows a sample cast.`}</Text>}
406 </Box>
407 )
408 } catch (err) {
409 const why = err instanceof Error ? err.message : String(err)
410 note(`error surface=${e.surface} ${why}`)
411 return <Text color="#f87171">{`Eagle Eye could not draw: ${why}`}</Text>
412 }
413 })
414}
415
416/** Opens the live page in the default browser. */
417const openInBrowser = async ($: { process: { run: (argv: readonly string[]) => Promise<unknown> } }, dir: string) => {
418 const tries: string[][] = /^[A-Za-z]:/.test(dir)
419 ? [['explorer.exe', `${dir.replace(/\//g, '\\')}\\index.html`]]
420 : [
421 ['open', pageUrl(dir)],
422 ['xdg-open', pageUrl(dir)],
423 ]
424 for (const argv of tries) {
425 try {
426 await $.process.run(argv)
427 return
428 } catch {
429 // Not on this platform: try the next opener.
430 }
431 }
432}
433hooks/mini.ts 294 lines1import type { AgentRec, AgentStatusWord, MainRec } from '../types'
2import type { IconKind } from './model'
3import { STATUS_COLOR, STATUS_LABEL, displayName, formatDuration, formatTokens, iconFor, isLive, prettyModel } from './model'
4
5/**
6 * The pane's small picture: the browser view's idea (orchestrator in the
7 * middle, agents round it, light running along the links) as one animated
8 * SVG a desktop pane can draw. Few shapes, no filters, well under the limit.
9 */
10
11export const MINI_W = 640
12export const MINI_H = 440
13const MAX_NODES = 8
14const NODE_R = 19
15const CORE_R = 38
16
17const COLOR: Record<IconKind, string> = {
18 doc: '#3496ff',
19 search: '#28afff',
20 code: '#b252ff',
21 mail: '#96a0eb',
22 chart: '#ffa834',
23 image: '#34f270',
24 bell: '#96a0eb',
25 globe: '#96e2ff',
26 pencil: '#34acff',
27 api: '#96a0eb',
28 gear: '#58ff52',
29 database: '#ffa234',
30 check: '#ffac3e',
31}
32
33const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
34const f = (n: number) => String(Math.round(n * 10) / 10)
35
36/** Every live agent first, then the newest finished, in spawn order. */
37export const pickNodes = (agents: AgentRec[]): { shown: AgentRec[]; hidden: number } => {
38 const live = agents.filter(a => isLive(a.status))
39 const done = agents.filter(a => !isLive(a.status)).sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt))
40 const ids = new Set([...live, ...done].slice(0, MAX_NODES).map(a => a.id))
41 const shown = agents.filter(a => ids.has(a.id))
42 return { shown, hidden: agents.length - shown.length }
43}
44
45/** Done with its work: drawn small, dim and wordless, and a little behind the live ones. */
46const finished = (s: AgentStatusWord) => s === 'completed' || s === 'stopped' || s === 'offline'
47const DONE_R = 10
48
49const quiet = (s: AgentStatusWord) => s === 'stopped' || s === 'offline' || s === 'failed'
50
51/** `now` phases the animations, so a redraw each second carries on instead of restarting. */
52export const renderMini = (main: MainRec, agents: AgentRec[], now: number): string => {
53 const sec = now / 1000
54 const begin = (dur: number, off = 0) => `-${f((((sec + off) % dur) + dur) % dur)}s`
55 const { shown, hidden } = pickNodes(agents)
56 const cx = MINI_W / 2
57 const cy = MINI_H / 2
58 const window = main.window || 200_000
59 const out: string[] = []
60
61 out.push(
62 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${MINI_W} ${MINI_H}" width="${MINI_W}" height="${MINI_H}" font-family="Inter,'Segoe UI',system-ui,sans-serif">`,
63 `<defs><radialGradient id="bg" cx="50%" cy="46%" r="75%"><stop offset="0" stop-color="#101c4e"/><stop offset=".55" stop-color="#060c26"/><stop offset="1" stop-color="#03040f"/></radialGradient>` +
64 `<radialGradient id="halo"><stop offset=".35" stop-color="#4f7bff" stop-opacity=".45"/><stop offset="1" stop-color="#7c3aed" stop-opacity="0"/></radialGradient>` +
65 `<linearGradient id="rim" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#22d3ee"/><stop offset=".5" stop-color="#60a5fa"/><stop offset="1" stop-color="#a855f7"/></linearGradient></defs>`,
66 `<rect width="${MINI_W}" height="${MINI_H}" rx="14" fill="url(#bg)"/>`,
67 )
68
69 // A few fixed stars, so the sky isn't flat.
70 for (let i = 0; i < 46; i++) {
71 const x = (i * 97 + 31) % MINI_W
72 const y = (i * 61 + 17) % MINI_H
73 const tw = i % 5 === 0
74 out.push(
75 `<circle cx="${x}" cy="${y}" r="${i % 7 === 0 ? 1.3 : 0.8}" fill="#d6e6ff" opacity="${i % 3 === 0 ? 0.7 : 0.35}">` +
76 (tw ? `<animate attributeName="opacity" values=".8;.15;.8" dur="${3 + (i % 4)}s" begin="${begin(3 + (i % 4), i)}" repeatCount="indefinite"/>` : '') +
77 '</circle>',
78 )
79 }
80
81 // Links, then nodes, so the lines pass behind the circles.
82 const n = shown.length
83 const places = shown.map((a, i) => {
84 const ang = -Math.PI / 2 + (i / Math.max(1, n)) * Math.PI * 2
85 const reach = finished(a.status) ? 1.08 : 1
86 return { a, x: cx + Math.cos(ang) * 236 * reach, y: cy + Math.sin(ang) * 140 * reach, i, done: finished(a.status) }
87 })
88
89 for (const { a, x, y, i, done } of places) {
90 const col = quiet(a.status) ? '#475569' : COLOR[iconFor(a.type, a.name)]
91 const rim = done ? DONE_R : NODE_R
92 const dx = x - cx
93 const dy = y - cy
94 const len = Math.hypot(dx, dy) || 1
95 const ax = cx + (dx / len) * (CORE_R + 4)
96 const ay = cy + (dy / len) * (CORE_R + 4)
97 const bx = x - (dx / len) * (rim + 3)
98 const by = y - (dy / len) * (rim + 3)
99 const bow = i % 2 === 0 ? 0.18 : -0.18
100 const qx = (ax + bx) / 2 - (by - ay) * bow
101 const qy = (ay + by) / 2 + (bx - ax) * bow
102 const d = `M${f(ax)} ${f(ay)}Q${f(qx)} ${f(qy)} ${f(bx)} ${f(by)}`
103 out.push(
104 done
105 ? `<path id="l${i}" d="${d}" fill="none" stroke="#475569" stroke-width="1" opacity=".22" stroke-dasharray="2 6"/>`
106 : `<path id="l${i}" d="${d}" fill="none" stroke="${col}" stroke-width="${quiet(a.status) ? 1 : 1.8}" opacity="${quiet(a.status) ? 0.4 : 0.75}"${quiet(a.status) ? ' stroke-dasharray="2 6"' : ''}/>`,
107 )
108 const flows = a.status === 'running' ? 2 : a.status === 'thinking' ? 1 : 0
109 const dur = a.status === 'running' ? 2.2 : 3.4
110 for (let k = 0; k < flows; k++) {
111 out.push(
112 `<circle r="3.2" fill="#f0f9ff"><animateMotion dur="${dur}s" begin="${begin(dur, (k * dur) / 2 + i * 0.3)}" repeatCount="indefinite"><mpath href="#l${i}"/></animateMotion></circle>`,
113 )
114 }
115 }
116
117 // The orchestrator.
118 out.push(
119 `<circle cx="${cx}" cy="${cy}" r="86" fill="url(#halo)"><animate attributeName="r" values="80;92;80" dur="4s" begin="${begin(4)}" repeatCount="indefinite"/></circle>`,
120 `<circle cx="${cx}" cy="${cy}" r="${CORE_R}" fill="#0a1442" stroke="url(#rim)" stroke-width="3.5"/>`,
121 `<circle cx="${cx}" cy="${cy}" r="${CORE_R + 9}" fill="none" stroke="#a78bfa" stroke-width="1.2" stroke-dasharray="3 7" opacity=".7"><animateTransform attributeName="transform" type="rotate" from="0 ${cx} ${cy}" to="360 ${cx} ${cy}" dur="40s" begin="${begin(40)}" repeatCount="indefinite"/></circle>`,
122 )
123 const coreLen = 2 * Math.PI * (CORE_R + 5)
124 const coreFill = Math.min(1, main.contextTokens / window)
125 if (coreFill > 0.004) {
126 out.push(
127 `<circle cx="${cx}" cy="${cy}" r="${CORE_R + 5}" fill="none" stroke="#38bdf8" stroke-width="2.4" stroke-linecap="round" stroke-dasharray="${f(coreFill * coreLen)} ${f(coreLen)}" transform="rotate(-90 ${cx} ${cy})"/>`,
128 )
129 }
130 out.push(
131 `<text x="${cx}" y="${cy - 2}" text-anchor="middle" font-size="13" font-weight="600" fill="#f8fafc">Orchestrator</text>`,
132 `<text x="${cx}" y="${cy + 13}" text-anchor="middle" font-size="10.5" fill="#94a3b8">${esc(prettyModel(main.model).replace(/^Claude /, ''))}</text>`,
133 `<text x="${cx}" y="${cy + 26}" text-anchor="middle" font-size="9.5" fill="#7dd3fc">${formatTokens(main.contextTokens)} ctx</text>`,
134 )
135
136 // The agents: finished ones first, so the live ones draw over them.
137 for (const { a, x, y, done } of places.filter(p => p.done)) {
138 const tone = a.status === 'completed' ? '#5f86b8' : '#475569'
139 out.push(
140 `<g transform="translate(${f(x)} ${f(y)})" opacity=".55"><title>${esc(displayName(a))} · ${STATUS_LABEL[a.status]} · ${formatTokens(a.contextTokens)} · ${formatDuration((a.endedAt ?? now) - a.startedAt)}</title>` +
141 `<circle r="${DONE_R}" fill="#070b1f" stroke="${tone}" stroke-width="1.4"/>` +
142 (a.status === 'completed' && done
143 ? `<path d="M-3.6 .4l2.4 2.4 4.8-5" stroke="${tone}" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`
144 : '') +
145 '</g>',
146 )
147 }
148 for (const { a, x, y, i } of places.filter(p => !p.done)) {
149 const dim = quiet(a.status)
150 const col = a.status === 'failed' ? '#ef4444' : dim ? '#475569' : COLOR[iconFor(a.type, a.name)]
151 const dot = STATUS_COLOR[a.status]
152 const active = a.status === 'running' || a.status === 'thinking'
153 const fill = Math.min(1, a.contextTokens / window)
154 const arcLen = 2 * Math.PI * (NODE_R + 5)
155 const ended = a.endedAt ?? now
156 out.push(`<g transform="translate(${f(x)} ${f(y)})">`)
157 if (!dim) {
158 out.push(
159 `<circle r="${NODE_R + 14}" fill="${col}" opacity=".13">${active ? `<animate attributeName="opacity" values=".08;.22;.08" dur="3s" begin="${begin(3, i)}" repeatCount="indefinite"/>` : ''}</circle>`,
160 )
161 }
162 if (a.status === 'running') {
163 out.push(
164 `<circle r="${NODE_R}" fill="none" stroke="${col}" stroke-width="1.6"><animate attributeName="r" values="${NODE_R};${NODE_R + 14}" dur="2.2s" begin="${begin(2.2, i * 0.4)}" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values=".8;0" dur="2.2s" begin="${begin(2.2, i * 0.4)}" repeatCount="indefinite"/></circle>`,
165 )
166 }
167 out.push(`<circle r="${NODE_R}" fill="#070b1f" stroke="${col}" stroke-width="${dim ? 1.5 : 2.6}"/>`)
168 if (!dim && fill > 0.004) {
169 out.push(
170 `<circle r="${NODE_R + 5}" fill="none" stroke="${col}" stroke-width="2" stroke-linecap="round" stroke-dasharray="${f(Math.max(1, fill * arcLen))} ${f(arcLen)}" transform="rotate(-90)" opacity=".9"/>`,
171 )
172 }
173 if (a.status === 'thinking') {
174 out.push(
175 `<circle r="${NODE_R + 5}" fill="none" stroke="${dot}" stroke-width="2.4" stroke-linecap="round" stroke-dasharray="14 ${f(arcLen - 14)}"><animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="1.4s" begin="${begin(1.4)}" repeatCount="indefinite"/></circle>`,
176 )
177 }
178 // An initial stands in for the icon: names are the point, not glyphs.
179 out.push(`<text y="5.5" text-anchor="middle" font-size="15" font-weight="700" fill="${dim ? '#64748b' : col}">${esc(displayName(a).charAt(0).toUpperCase())}</text>`)
180 if (a.status === 'completed') {
181 out.push(`<g transform="translate(14 -14)"><circle r="7" fill="#0b1530" stroke="#60a5fa" stroke-width="1.3"/><path d="M-3 .2l2 2 4-4.2" stroke="#bfdbfe" stroke-width="1.6" fill="none" stroke-linecap="round"/></g>`)
182 }
183 const name = displayName(a).replace(/ Agent$/, '')
184 out.push(
185 `<text y="${NODE_R + 20}" text-anchor="middle" font-size="12" font-weight="600" fill="${dim ? '#94a3b8' : '#f1f5f9'}">${esc(name.length > 18 ? `${name.slice(0, 17)}…` : name)}</text>`,
186 `<circle cx="${-(STATUS_LABEL[a.status].length * 2.9 + 8)}" cy="${NODE_R + 30}" r="3" fill="${dot}"/>`,
187 `<text x="-${f(STATUS_LABEL[a.status].length * 2.9 + 2)}" y="${NODE_R + 33.5}" font-size="10" fill="${dim ? '#64748b' : dot}">${STATUS_LABEL[a.status]}</text>`,
188 )
189 if (!dim || a.contextTokens > 0) {
190 out.push(
191 `<text y="${NODE_R + 46}" text-anchor="middle" font-size="10" fill="#94a3b8">${formatTokens(a.contextTokens)} · ${formatDuration(ended - a.startedAt)}</text>`,
192 )
193 }
194 out.push('</g>')
195 }
196
197 if (hidden > 0) out.push(`<text x="16" y="${MINI_H - 14}" font-size="11" fill="#64748b">+${hidden} more</text>`)
198 out.push('</svg>')
199 return out.join('')
200}
201
202/**
203 * The pane's pseudo-3D picture: agents orbit the orchestrator on a tilted
204 * ellipse. Nodes are scaled by depth (nearer is bigger) and drawn back to
205 * front; the orbit itself is SMIL (translate + scale sampled round the loop).
206 * A redraw each second re-sorts by depth and carries the phase on.
207 */
208const ORBIT_S = 48
209const STEPS = 24
210const RX = 236
211const RY = 80
212
213export const renderMini3D = (main: MainRec, agents: AgentRec[], now: number): string => {
214 const sec = now / 1000
215 const cx = MINI_W / 2
216 const cy = MINI_H / 2
217 const { shown, hidden } = pickNodes(agents)
218 const n = shown.length
219 const begin = (off: number) => `-${f((((sec + off) % ORBIT_S) + ORBIT_S) % ORBIT_S)}s`
220 const out: string[] = []
221 out.push(
222 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${MINI_W} ${MINI_H}" width="${MINI_W}" height="${MINI_H}" font-family="Inter,'Segoe UI',system-ui,sans-serif">`,
223 `<defs><radialGradient id="bg" cx="50%" cy="46%" r="75%"><stop offset="0" stop-color="#101c4e"/><stop offset=".55" stop-color="#060c26"/><stop offset="1" stop-color="#03040f"/></radialGradient>` +
224 `<radialGradient id="halo"><stop offset=".35" stop-color="#4f7bff" stop-opacity=".45"/><stop offset="1" stop-color="#7c3aed" stop-opacity="0"/></radialGradient>` +
225 `<linearGradient id="rim" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#22d3ee"/><stop offset=".5" stop-color="#60a5fa"/><stop offset="1" stop-color="#a855f7"/></linearGradient></defs>`,
226 `<rect width="${MINI_W}" height="${MINI_H}" rx="14" fill="url(#bg)"/>`,
227 )
228 for (let i = 0; i < 30; i++) {
229 out.push(`<circle cx="${(i * 97 + 31) % MINI_W}" cy="${(i * 61 + 17) % MINI_H}" r="${i % 7 === 0 ? 1.3 : 0.8}" fill="#d6e6ff" opacity="${i % 3 === 0 ? 0.7 : 0.35}"/>`)
230 }
231 out.push(`<ellipse cx="${cx}" cy="${cy}" rx="${RX}" ry="${RY}" fill="none" stroke="#3b4a8f" stroke-width="1" stroke-dasharray="3 7" opacity=".5"/>`)
232
233 const items = shown
234 .map((a, i) => {
235 const off = (i / Math.max(1, n)) * ORBIT_S
236 const theta = (((sec + off) % ORBIT_S) / ORBIT_S) * Math.PI * 2
237 return { a, off, depth: Math.sin(theta) }
238 })
239 .sort((p, q) => p.depth - q.depth)
240
241 const sampled = (fn: (th: number) => string) => Array.from({ length: STEPS + 1 }, (_, k) => fn((k / STEPS) * Math.PI * 2)).join(';')
242 const scaleOf = (th: number) => 0.78 + 0.34 * Math.sin(th)
243
244 const node = ({ a, off, depth }: (typeof items)[number]) => {
245 const done = finished(a.status)
246 const dim = quiet(a.status)
247 const col = a.status === 'failed' ? '#ef4444' : dim ? '#475569' : COLOR[iconFor(a.type, a.name)]
248 const dot = STATUS_COLOR[a.status]
249 const r = done ? DONE_R : NODE_R
250 const fade = f((done ? 0.55 : 1) * (0.55 + 0.45 * ((depth + 1) / 2)))
251 const timing = `dur="${ORBIT_S}s" begin="${begin(off)}" repeatCount="indefinite"`
252 const name = displayName(a).replace(/ Agent$/, '')
253 const spoke = done
254 ? ''
255 : `<line x1="${cx}" y1="${cy}" stroke="${col}" stroke-width="1.2" opacity=".35"><animate attributeName="x2" values="${sampled(th => f(cx + RX * Math.cos(th)))}" ${timing}/><animate attributeName="y2" values="${sampled(th => f(cy + RY * Math.sin(th)))}" ${timing}/></line>`
256 return (
257 spoke +
258 `<g opacity="${fade}"><animateTransform attributeName="transform" type="translate" values="${sampled(th => `${f(cx + RX * Math.cos(th))} ${f(cy + RY * Math.sin(th))}`)}" ${timing}/>` +
259 `<title>${esc(displayName(a))} · ${STATUS_LABEL[a.status]} · ${formatTokens(a.contextTokens)}</title>` +
260 `<g><animateTransform attributeName="transform" type="scale" values="${sampled(th => f(scaleOf(th)))}" ${timing}/>` +
261 (dim || done ? '' : `<circle r="${r + 12}" fill="${col}" opacity=".14"/>`) +
262 `<circle r="${r}" fill="#070b1f" stroke="${col}" stroke-width="${dim || done ? 1.4 : 2.6}"/>` +
263 (done ? '' : `<text y="5.5" text-anchor="middle" font-size="15" font-weight="700" fill="${dim ? '#64748b' : col}">${esc(displayName(a).charAt(0).toUpperCase())}</text>`) +
264 (done
265 ? ''
266 : `<text y="${r + 18}" text-anchor="middle" font-size="12" font-weight="600" fill="${dim ? '#94a3b8' : '#f1f5f9'}">${esc(name.length > 16 ? `${name.slice(0, 15)}…` : name)}</text>` +
267 `<text y="${r + 32}" text-anchor="middle" font-size="10" fill="${dot}">${STATUS_LABEL[a.status]}</text>`) +
268 '</g></g>'
269 )
270 }
271
272 for (const it of items.filter(p => p.depth < 0)) out.push(node(it))
273 const window = main.window || 200_000
274 const coreFill = Math.min(1, main.contextTokens / window)
275 const coreLen = 2 * Math.PI * (CORE_R + 5)
276 out.push(
277 `<circle cx="${cx}" cy="${cy}" r="86" fill="url(#halo)"/>`,
278 `<circle cx="${cx}" cy="${cy}" r="${CORE_R}" fill="#0a1442" stroke="url(#rim)" stroke-width="3.5"/>`,
279 )
280 if (coreFill > 0.004) {
281 out.push(
282 `<circle cx="${cx}" cy="${cy}" r="${CORE_R + 5}" fill="none" stroke="#38bdf8" stroke-width="2.4" stroke-linecap="round" stroke-dasharray="${f(coreFill * coreLen)} ${f(coreLen)}" transform="rotate(-90 ${cx} ${cy})"/>`,
283 )
284 }
285 out.push(
286 `<text x="${cx}" y="${cy - 2}" text-anchor="middle" font-size="13" font-weight="600" fill="#f8fafc">Orchestrator</text>`,
287 `<text x="${cx}" y="${cy + 13}" text-anchor="middle" font-size="10.5" fill="#94a3b8">${esc(prettyModel(main.model).replace(/^Claude /, ''))}</text>`,
288 )
289 for (const it of items.filter(p => p.depth >= 0)) out.push(node(it))
290 if (hidden > 0) out.push(`<text x="16" y="${MINI_H - 14}" font-size="11" fill="#64748b">+${hidden} more</text>`)
291 out.push('</svg>')
292 return out.join('')
293}
294hooks/model.ts 142 lines1import type { AgentRec, AgentStatusWord, MainRec } from '../types'
2
3/** The agent-network design's icons. */
4export type IconKind =
5 | 'doc' | 'search' | 'code' | 'mail' | 'chart' | 'image' | 'bell'
6 | 'globe' | 'pencil' | 'api' | 'gear' | 'database' | 'check'
7
8/** The design's statuses: it has no 'failed', so a failed agent shows as stopped. */
9export type DesignStatus = 'running' | 'thinking' | 'waiting' | 'idle' | 'completed' | 'stopped' | 'offline'
10
11export const designStatus = (s: AgentStatusWord): DesignStatus => (s === 'failed' ? 'stopped' : s)
12
13export const STATUS_LABEL: Record<AgentStatusWord, string> = {
14 running: 'Running',
15 thinking: 'Thinking',
16 waiting: 'Waiting',
17 idle: 'Idle',
18 completed: 'Completed',
19 failed: 'Failed',
20 stopped: 'Stopped',
21 offline: 'Offline',
22}
23
24export const STATUS_COLOR: Record<AgentStatusWord, string> = {
25 running: '#22c55e',
26 thinking: '#fbbf24',
27 waiting: '#fb923c',
28 idle: '#eab308',
29 completed: '#60a5fa',
30 failed: '#f87171',
31 stopped: '#64748b',
32 offline: '#475569',
33}
34
35const ICON_RULES: [RegExp, IconKind][] = [
36 [/explor|research|search|find|locat|investigat/, 'search'],
37 [/plan|architect|spec/, 'doc'],
38 [/test|qa\b|verif|check/, 'check'],
39 [/web|fetch|browser|http|url|chrome/, 'globe'],
40 [/\bdb\b|sql|database|data\b|query/, 'database'],
41 [/review|audit|inspect/, 'search'],
42 [/vision|image|screenshot|design|figma|visual/, 'image'],
43 [/analy|stat|metric|perf|bench/, 'chart'],
44 [/mail|gmail|inbox/, 'mail'],
45 [/notif|alert|push|bell/, 'bell'],
46 [/\bapi\b|endpoint|graphql/, 'api'],
47 [/write|writer|doc|readme|copy/, 'pencil'],
48 [/code|codewright|implement|build|fix|refactor|edit|builder/, 'code'],
49 [/exec|run|deploy|shell|general|task|worker/, 'gear'],
50]
51
52export const iconFor = (type: string, name: string): IconKind => {
53 const words = `${type} ${name}`.toLowerCase()
54 return ICON_RULES.find(([re]) => re.test(words))?.[1] ?? 'gear'
55}
56
57export const isLive = (s: AgentStatusWord) =>
58 s === 'running' || s === 'thinking' || s === 'waiting' || s === 'idle'
59
60export const formatTokens = (n: number): string => {
61 if (n < 1000) return `${Math.round(n)}`
62 if (n < 1_000_000) return `${Math.round(n / 1000)}K`
63 return `${(n / 1_000_000).toFixed(n < 10_000_000 ? 2 : 1)}M`
64}
65
66export const formatDuration = (ms: number): string => {
67 const s = Math.max(0, Math.floor(ms / 1000))
68 const h = Math.floor(s / 3600)
69 const m = Math.floor((s % 3600) / 60)
70 const sec = s % 60
71 const two = (n: number) => String(n).padStart(2, '0')
72 if (h > 0) return `${h}:${two(m)}:${two(sec)}`
73 return `${m}:${two(sec)}`
74}
75
76/** `claude-opus-5-5` -> `Claude Opus 5.5`; anything else passes through. */
77export const prettyModel = (id: string | undefined): string => {
78 if (!id) return 'Claude'
79 const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?/.exec(id)
80 if (!m) return id
81 const [, name = '', major = '', minor] = m
82 const family = name.charAt(0).toUpperCase() + name.slice(1)
83 return `Claude ${family} ${major}${minor ? `.${minor}` : ''}`
84}
85
86/** A subagent's name: its own, else its description, else its type, title-cased. */
87export const displayName = (rec: Pick<AgentRec, 'name' | 'type'>): string => {
88 const raw = (rec.name || rec.type || 'Agent').trim()
89 const titled = raw.replace(/(^|[\s_-])([a-z])/g, (_, sep: string, c: string) =>
90 (sep === '_' || sep === '-' ? ' ' : sep) + c.toUpperCase(),
91 )
92 return titled.length > 22 ? `${titled.slice(0, 21)}…` : titled
93}
94
95type DemoSpec = [name: string, type: string, status: AgentStatusWord, tokensK: number, elapsedS: number]
96
97// The reference picture's cast, clockwise from the top-left.
98const DEMO: DemoSpec[] = [
99 ['Research Agent', 'research', 'running', 423, 734],
100 ['Planner Agent', 'Plan', 'running', 312, 501],
101 ['Code Agent', 'code', 'running', 621, 948],
102 ['Analysis Agent', 'analysis', 'thinking', 198, 392],
103 ['Notification Agent', 'notification', 'stopped', 0, 0],
104 ['Writer Agent', 'writer', 'idle', 42, 131],
105 ['Executor Agent', 'executor', 'running', 512, 1083],
106 ['QA Agent', 'qa', 'waiting', 0, 0],
107 ['DB Agent', 'db', 'running', 287, 557],
108 ['API Agent', 'api', 'offline', 0, 0],
109 ['Web Agent', 'web', 'completed', 154, 462],
110 ['Vision Agent', 'vision', 'running', 391, 716],
111 ['Email Agent', 'email', 'stopped', 0, 0],
112]
113
114/** The demo cast as of `now`: running agents keep growing context while it runs. */
115export const demoAgents = (since: number, now: number): AgentRec[] => {
116 const ran = (now - since) / 1000
117 return DEMO.map(([name, type, status, tokensK, elapsedS], i) => {
118 const grows = status === 'running' || status === 'thinking'
119 const startedAt = since - elapsedS * 1000 + i
120 return {
121 id: `demo-${i}`,
122 name,
123 type,
124 status,
125 startedAt,
126 endedAt: grows || status === 'idle' || status === 'waiting' ? undefined : startedAt + elapsedS * 1000,
127 contextTokens: tokensK * 1000 + (grows ? Math.round(ran * (180 + i * 37)) : 0),
128 outputTokens: 0,
129 toolCalls: 0,
130 }
131 })
132}
133
134export const demoMain = (since: number, now: number, model: string | undefined): MainRec => ({
135 model: model ?? 'claude-opus-5-5',
136 status: 'running',
137 startedAt: since - 1_402_000,
138 contextTokens: 412_000 + Math.round((now - since) / 1000) * 90,
139 window: 1_000_000,
140 outputTokens: 0,
141})
142types/index.d.ts 49 lines1export type AgentStatusWord =
2 | 'running'
3 | 'thinking'
4 | 'waiting'
5 | 'idle'
6 | 'completed'
7 | 'failed'
8 | 'stopped'
9 | 'offline'
10
11export type AgentRec = {
12 id: string
13 parentId?: string
14 name: string
15 type: string
16 model?: string
17 status: AgentStatusWord
18 startedAt: number
19 endedAt?: number
20 contextTokens: number
21 outputTokens: number
22 toolCalls: number
23 lastTool?: string
24}
25
26export type MainRec = {
27 model?: string
28 status: AgentStatusWord
29 startedAt: number
30 contextTokens: number
31 window: number
32 outputTokens: number
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 'agents-eagle-eye': {
38 agents: Record<string, AgentRec>
39 main: MainRec
40 /** When demo mode started, in epoch ms; 0 when off. */
41 demo: number
42 /** Which browser view the mod last asked for. */
43 view: '2d' | '3d'
44 /** Bumped on every explicit view choice; the page applies `view` only when it changes. */
45 viewSeq: number
46 }
47 }
48}
49