SLOPSHOPPER

agents-eagle-eye

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

newpaneguardcommandprocesstimer
v0.1.0no licenseupdated 2026-10-24nurcse1-maker/agents-eagle-eye
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agents-eagle-eye
│ ┃ Eagle Eye ✕ › fix the failing auth test and add an audit log call │ ┃ Eagle Eye · Claude · 0 live / 0 agents │ ┃ View: 2D · switch to 3D ⏺ Read(src/auth.ts) │ ┃ No subagents yet. /agents-eagle-eye demo ⎿ Read 6 lines │ ┃ shows a sample cast. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /agents-eagle-eye │ ⎿ agents-eagle-eye: Eagle Eye live view opened in your browser: fi │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Eagle Eye
Eagle Eye · Claude · 0 live / 0 agents View: 2D · switch to 3D No subagents yet. /agents-eagle-eye demo shows a sample cast.
README

agents-eagle-eye

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.

2D and 3D views

The browser page has two views of the same agents, switched with the 2D | 3D tab at the top right:

  • 2D is the flat canvas design.
  • 3D is an orbiting scene: the orchestrator is an orb in the middle and the agents are planets around it. It needs WebGL2. Without it, the 3D tab is disabled and the page stays on 2D.

Which view you see at load, in order of priority:

  1. The URL hash: #3d or #2d (for example index.html#3d). Deep links to either view work.
  2. A view the pane asked for since this page last looked (/agents-eagle-eye 3d|2d or the View button).
  3. The view you used last time (remembered in the browser).
  4. 2D.

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.

3D controls

  • Drag to orbit.
  • Mouse wheel (or pinch) to zoom.
  • Double-click, or press R, to reset to the default view.
  • Arrow keys orbit; plus and minus zoom (click the scene first so it has focus).
  • Click a planet or the orb, hover for a tooltip. A Bloom button at the bottom right turns the glow on or off.

Debug flags (query string)

  • ?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.

How it works

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.

Layout

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

Install

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.

Rebuild the browser bundles

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.

Source 4 files
hooks/register.tsx 433 lines
1import { 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}
433
hooks/mini.ts 294 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
294
hooks/model.ts 142 lines
1import 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})
142
types/index.d.ts 49 lines
1export 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