SLOPSHOPPER

orrery

KOZMOS Orrery: subagents as planets circling a central sun above the prompt. Running agents orbit at a speed set by their tokens per second, finished ones…

newbandcommandtimeragents
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/orrery
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orrery
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /orrery ⎿ orrery: Orrery hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

KOZMOS Orrery

Subagents as planets circling a central sun (the main Claude), drawn above the prompt.

  • Running agents orbit on dotted ellipses; the lane follows spawn order and the angular speed follows the agent's output tokens per second (from turn.step usage), so a streaming agent whirls and a waiting one crawls. Each planet trails a comet tail; its glyph says its type (◍ explore, ◆ plan, ◈ review, ● other).
  • Finished agents settle as faint dots on the outer ring; failed ones flash a red ✖, then dim.
  • The sun flickers, and its corona breathes faster while the main loop streams.
  • A legend lists up to four agents: glyph, description (clipped), type, elapsed time and tokens/s.

Terminal: a Client module draws a 4–5 row orbit field in braille with colored planets at 30 fps, legend beside it. Desktop: one SVG night sky: CSS-rotated orbit lanes (each lane's period from the agent's speed, its phase kept across redraws), a pulsing radial-gradient sun, twinkling stars and a theme-aware legend.

Hidden until the first subagent of the session; it stays while any agent runs and for 5 minutes after the last one finished. ✕ hides it; /orrery toggles it.

Data: agent.spawn (description, type, model → agentId), turn.step (usage per agentId), turn.complete (reason), and $.agent.list() every 2 s to reconcile status.

Türkçe

Alt ajanlar, merkezdeki güneşin (ana Claude) etrafında dönen gezegenler olarak istem satırının üstünde.

  • Çalışan ajanlar noktalı elipslerde döner; yörünge doğuş sırasını, açısal hız ise ajanın saniyedeki çıktı token'ını izler: akış hâlindeki ajan hızla döner, bekleyen ağır ağır ilerler. Her gezegenin kuyruklu yıldız izi vardır; simgesi türünü söyler.
  • Biten ajanlar dış halkada soluk noktalara dönüşür; başarısız olanlar kırmızı ✖ ile yanıp söner, sonra solar.
  • Ana döngü akarken güneş titrer ve tacı daha hızlı nefes alır.
  • Açıklama: en çok dört ajan; simge, açıklama, tür, geçen süre ve token/sn.

Terminal: 4–5 satırlık braille yörünge alanı, 30 fps. Masaüstü: CSS ile döndürülen yörüngeler, nabız atan güneş ve parıldayan yıldızlarla tek bir SVG gece göğü.

Oturumun ilk alt ajanına kadar gizlidir; bir ajan çalıştıkça ve sonuncusu bittikten sonra 5 dakika görünür. ✕ gizler; /orrery açar/kapatır.

Source 5 files
hooks/register.tsx 253 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentStatus, EngineInterface, Register } from 'claude-code'
3
4import type { OrbitAgent, OrbitStatus, OrrerySnap } from '../types'
5import { pxOf } from './lib/kz.ts'
6import { omegaOf, orreryAlt, orrerySvg } from './sky.ts'
7
8const snapAtom = atom({ plugin: 'orrery', key: 'snap' } as const, null)
9const hiddenAtom = atom({ plugin: 'orrery', key: 'isHidden' } as const, false)
10
11/** After the last agent finishes, the band lingers this long. */
12const LINGER_MS = 5 * 60_000
13/** Tokens per second are measured over this window. */
14const RATE_MS = 20_000
15const TAU = Math.PI * 2
16
17type Sample = { at: number; n: number }
18type Rec = OrbitAgent & { samples: Sample[] }
19
20// Live collector; the band draws from the snapshot published to $.state.
21let agents = new Map<string, Rec>()
22let nextOrder = 0
23let sun: Sample[] = []
24let isWorking = false
25let lastEnd = 0
26let lastTick = 0
27let ticks = 0
28let lastKey = ''
29
30function statusOf(s: AgentStatus): OrbitStatus {
31  if (s === 'failed' || s === 'killed') return 'fail'
32  if (s === 'completed' || s === 'idle') return 'done'
33  return 'run'
34}
35
36function addAgent(id: string, desc: string, type: string, model: string, at: number): Rec {
37  const known = agents.get(id)
38  if (known) {
39    known.desc ||= desc
40    known.type = known.type === 'agent' && type ? type : known.type
41    known.model ||= model
42    return known
43  }
44  const order = nextOrder++
45  const rec: Rec = {
46    id, desc, type: type || 'agent', model, order, status: 'run', start: at, end: null,
47    tps: 0, tokens: 0, angle: (order * 2.39996) % TAU, omega: omegaOf(0), samples: [],
48  }
49  agents.set(id, rec)
50  return rec
51}
52
53function finish(rec: Rec, status: OrbitStatus, at: number): void {
54  if (rec.status !== 'run' || status === 'run') return
55  rec.status = status
56  rec.end = at
57  rec.tps = 0
58  lastEnd = at
59}
60
61function rate(samples: readonly Sample[], now: number): number {
62  return samples.filter(s => now - s.at < RATE_MS).reduce((n, s) => n + s.n, 0) / (RATE_MS / 1000)
63}
64
65async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
66  await update($, hiddenAtom, () => isHidden)
67  try {
68    await $.store.set('hidden', isHidden)
69  } catch {
70    // Not persisted; the session still honours it.
71  }
72}
73
74async function reconcile($: EngineInterface, now: number): Promise<void> {
75  try {
76    for (const info of await $.agent.list()) {
77      const status = statusOf(info.status)
78      const rec = agents.get(info.id) ?? (status === 'run' ? addAgent(info.id, info.description, info.type, '', now) : undefined)
79      if (!rec) continue
80      rec.desc ||= info.description
81      if (rec.type === 'agent' && info.type) rec.type = info.type
82      finish(rec, status, now)
83    }
84  } catch {
85    // The roster is not readable now; the hooks still track spawns.
86  }
87}
88
89async function tick($: EngineInterface): Promise<void> {
90  const now = await $.clock.now()
91  const dt = lastTick ? Math.min(5000, now - lastTick) : 0
92  lastTick = now
93  ticks++
94  const hasRunning = [...agents.values()].some(a => a.status === 'run')
95  if (hasRunning ? ticks % 2 === 0 : ticks % 10 === 0) await reconcile($, now)
96  for (const a of agents.values()) {
97    a.samples = a.samples.filter(s => now - s.at < RATE_MS)
98    if (a.status !== 'run') continue
99    a.tps = rate(a.samples, now)
100    a.omega = omegaOf(a.tps)
101    a.angle = (a.angle + (a.omega * dt) / 1000) % TAU
102  }
103  sun = sun.filter(s => now - s.at < RATE_MS)
104  await publish($, now)
105}
106
107async function publish($: EngineInterface, at?: number): Promise<void> {
108  const now = at ?? (await $.clock.now())
109  // Keep the running and the latest dozen finished.
110  const all = [...agents.values()].sort((a, b) => a.order - b.order)
111  const finished = all.filter(a => a.status !== 'run').slice(-12)
112  const list = all.filter(a => a.status === 'run' || finished.includes(a))
113  const snap: OrrerySnap = {
114    now,
115    agents: list.map(({ samples: _, ...a }) => ({ ...a, angle: Math.round(a.angle * 1000) / 1000, tps: Math.round(a.tps * 10) / 10 })),
116    sunTps: Math.round(rate(sun, now)),
117    isWorking,
118    lastEnd,
119  }
120  const isMoving = list.some(a => a.status === 'run' || (a.status === 'fail' && a.end !== null && now - a.end < 5000))
121  const isShown = list.length > 0 && (list.some(a => a.status === 'run') || now - lastEnd < LINGER_MS)
122  const key = JSON.stringify({ ...snap, now: isMoving ? now : 0, isShown })
123  if (key === lastKey) return
124  await update($, snapAtom, () => snap)
125  // Noted once written, so a refused write is tried again on the next tick.
126  lastKey = key
127}
128
129export const register: Register = on => {
130  on('session.start', async ($, e, next) => {
131    const started = await next(e)
132    agents = new Map()
133    nextOrder = 0
134    sun = []
135    isWorking = false
136    lastEnd = 0
137    lastTick = 0
138    ticks = 0
139    lastKey = ''
140    await $.command.register({ name: 'orrery', description: 'KOZMOS: show or hide the Orrery of subagents above the prompt', immediate: true })
141    try {
142      const stored = await $.store.get('hidden')
143      if (typeof stored === 'boolean') await update($, hiddenAtom, () => stored)
144    } catch {
145      // Fresh store.
146    }
147    await reconcile($, await $.clock.now())
148    await publish($).catch(() => undefined)
149    $.clock.every(1000, () => void tick($).catch(() => undefined))
150    return started
151  })
152
153  on('command.run', { command: 'orrery' }, async $ => {
154    const isHidden = !(await read($, hiddenAtom))
155    await setHidden($, isHidden)
156    return { text: isHidden ? 'Orrery hidden.' : 'Orrery shown.' }
157  })
158
159  // A new planet: the spawn tells its task, type and model; the result its id.
160  on('agent.spawn', async ($, e, next) => {
161    const r = await next(e)
162    if ('agentId' in r && typeof r.agentId === 'string') {
163      addAgent(r.agentId, e.description, e.subagentType, r.model || e.model || '', await $.clock.now())
164      void publish($).catch(() => undefined)
165    }
166    return r
167  }).catch(($, e, next) => next(e))
168
169  on('turn.start', async ($, e, next) => {
170    isWorking = true
171    void publish($).catch(() => undefined)
172    return next(e)
173  })
174
175  // Each model request's output tokens set its loop's speed (the sun's on the main loop).
176  on('turn.step', async function* ($, e, next) {
177    const r = yield* next(e)
178    try {
179      const n = r.usage?.output_tokens ?? 0
180      if (n > 0) {
181        const at = await $.clock.now()
182        if (e.agentId === undefined) sun.push({ at, n })
183        else {
184          const rec = agents.get(e.agentId) ?? addAgent(e.agentId, '', 'agent', e.model, at)
185          rec.samples.push({ at, n })
186          rec.tokens += n
187          rec.model ||= e.model
188        }
189      }
190    } catch {
191      // A step without usage counts nothing.
192    }
193    return r
194  })
195
196  on('turn.complete', async ($, e, next) => {
197    const at = await $.clock.now()
198    if (e.agentId === undefined) isWorking = false
199    else {
200      const rec = agents.get(e.agentId)
201      if (rec) finish(rec, e.reason === 'answer' ? 'done' : 'fail', at)
202    }
203    void publish($, at).catch(() => undefined)
204    return next(e)
205  })
206
207  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
208    const drawn = await next(e)
209    if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
210    const snap = await read($, snapAtom)
211    if (!snap || snap.agents.length === 0) return drawn
212    const hasRunning = snap.agents.some(a => a.status === 'run')
213    if (!hasRunning && snap.now - snap.lastEnd >= LINGER_MS) return drawn
214
215    const { Box, Button } = $.ui.resolve(e)
216    const hide = <Button key="orrery-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
217
218    if (e.surface !== 'terminal') {
219      const { Svg } = $.ui.resolve(e)
220      const W = pxOf(e.props.bodyColumns)
221      const H = e.props.maxRows >= 10 ? 112 : 92
222      return (
223        <Box flexDirection="column">
224          {drawn}
225          <Box flexDirection="row">
226            <Svg source={orrerySvg(snap.agents, snap.now, snap.sunTps, snap.isWorking, W, H)} alt={orreryAlt(snap.agents)} width={W} height={H} />
227            {hide}
228          </Box>
229        </Box>
230      )
231    }
232
233    const { Client } = $.ui.resolve(e)
234    const cols = Math.max(40, e.props.bodyColumns || 80)
235    const rows = e.props.maxRows >= 12 ? 5 : 4
236    return (
237      <Box flexDirection="column">
238        {drawn}
239        <Box flexDirection="row">
240          <Client
241            key="orrery"
242            module="./scene.tsx"
243            width={cols - 2}
244            height={rows}
245            props={{ agents: snap.agents, now: snap.now, sunTps: snap.sunTps, isWorking: snap.isWorking, width: cols - 2, rows }}
246          />
247          {hide}
248        </Box>
249      </Box>
250    )
251  })
252}
253
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/sky.ts 303 lines
1// Orrery's pure drawing: orbits, planets and sun as a cell frame for the
2// terminal and as one SVG with CSS orbits for the desktop. No `$` here.
3import { KZ, clip, fitText, fmtClock, mix, noise, svg, svgText } from './lib/kz.ts'
4
5export type Agent = {
6  id: string
7  desc: string
8  type: string
9  model: string
10  order: number
11  status: 'run' | 'done' | 'fail'
12  start: number
13  end: number | null
14  tps: number
15  tokens: number
16  angle: number
17  omega: number
18}
19export type Seg = { s: string; c?: string; b?: boolean; d?: boolean }
20
21const PALETTE = [KZ.violet, KZ.cyan, KZ.magenta, KZ.lime, KZ.amber, KZ.blue, KZ.teal, KZ.yellow]
22export const DIM = '#5b6470'
23const TAU = Math.PI * 2
24
25export function agentColor(order: number): string {
26  return PALETTE[((order % PALETTE.length) + PALETTE.length) % PALETTE.length]!
27}
28
29/** A glyph per agent type: explorers ◍, planners ◆, reviewers ◈, the rest ●. */
30export function planetGlyph(type: string): string {
31  const t = type.toLowerCase()
32  if (t.includes('explore') || t.includes('search')) return '◍'
33  if (t.includes('plan') || t.includes('architect')) return '◆'
34  if (t.includes('review') || t.includes('audit')) return '◈'
35  return '●'
36}
37
38/** Angular speed (rad/s) from output tokens per second: a crawl when silent, a whirl when streaming. */
39export function omegaOf(tps: number): number {
40  return 0.22 + 2.4 * (1 - Math.exp(-Math.max(0, tps) / 55))
41}
42
43/** Where an agent stands at `t`: its angle extrapolated from the snapshot. */
44export function angleAt(a: Agent, snapNow: number, t: number): number {
45  return a.status === 'run' ? a.angle + (a.omega * (t - snapNow)) / 1000 : a.angle
46}
47
48/** Which agents the legend lists: running first (by spawn), then the latest to finish. */
49export function legendAgents(agents: readonly Agent[], max = 4): Agent[] {
50  const run = agents.filter(a => a.status === 'run').sort((a, b) => a.order - b.order)
51  const rest = agents.filter(a => a.status !== 'run').sort((a, b) => (b.end ?? 0) - (a.end ?? 0))
52  return [...run, ...rest].slice(0, max)
53}
54
55export function elapsedOf(a: Agent, now: number): number {
56  return (a.end ?? now) - a.start
57}
58
59const LEFT = [0x01, 0x02, 0x04, 0x40]
60const RIGHT = [0x08, 0x10, 0x20, 0x80]
61
62type Cell = { ch: string; c: string; b?: boolean }
63
64/** The orbit field: `w` × `h` cells, the sun at the center, newest frame at `t`. */
65export function fieldFrame(agents: readonly Agent[], snapNow: number, t: number, w: number, h: number, sunTps: number, isWorking: boolean): Seg[][] {
66  const W = Math.max(9, Math.floor(w))
67  const H = Math.max(3, Math.floor(h))
68  const cells: (Cell | undefined)[][] = Array.from({ length: H }, () => Array.from({ length: W }, () => undefined))
69  const bits: number[][] = Array.from({ length: H }, () => Array.from({ length: W }, () => 0))
70  const bitColor: string[][] = Array.from({ length: H }, () => Array.from({ length: W }, () => DIM))
71  // Braille space: 2 dots per cell across, 4 down.
72  const cx = W // dots
73  const cy = H * 2
74  const dot = (x: number, y: number, c: string) => {
75    // Every orbit lies inside the field; only a point that is not a number misses it.
76    const dx = Math.round(x)
77    const dy = Math.round(y)
78    const col = Math.floor(dx / 2)
79    const row = Math.floor(dy / 4)
80    const r = bits[row]
81    const rc = bitColor[row]
82    if (!r || !rc) return
83    r[col] = r[col]! | (dx % 2 === 0 ? LEFT : RIGHT)[dy % 4]!
84    rc[col] = c
85  }
86  const put = (x: number, y: number, ch: string, c: string, b?: boolean) => {
87    const col = Math.floor(Math.round(x) / 2)
88    const row = Math.floor(Math.round(y) / 4)
89    const line = cells[row]
90    if (line && col >= 0 && col < W) line[col] = { ch, c, b }
91  }
92
93  // Orbits: the outer ring holds the finished; running agents use up to 3 inner ones.
94  const outerRx = W - 1.5
95  const outerRy = H * 2 - 0.6
96  const running = agents.filter(a => a.status === 'run')
97  const lanes = Math.max(1, Math.min(3, running.length))
98  const orbit = (k: number) => {
99    const f = (k + 1) / (lanes + 1)
100    return { rx: Math.max(3, outerRx * (0.3 + 0.62 * f)), ry: Math.max(1.2, outerRy * (0.3 + 0.62 * f)) }
101  }
102  const ellipse = (rx: number, ry: number, every: number, c: string, phase = 0) => {
103    const n = Math.max(12, Math.round(TAU * Math.sqrt((rx * rx + ry * ry) / 2)))
104    for (let i = 0; i < n; i++) {
105      if ((i + phase) % every !== 0) continue
106      const a = (i / n) * TAU
107      dot(cx + Math.cos(a) * rx, cy + Math.sin(a) * ry, c)
108    }
109  }
110  ellipse(outerRx, outerRy, 3, mix(DIM, '#000000', 0.15), Math.floor(t / 600) % 3)
111  for (let k = 0; k < lanes; k++) {
112    const { rx, ry } = orbit(k)
113    ellipse(rx, ry, 2, DIM)
114  }
115
116  // The sun: a flickering star, a corona that breathes with the main loop's output.
117  const heat = Math.min(1, sunTps / 80)
118  const speed = isWorking ? 140 - heat * 70 : 420
119  const phase = Math.floor(t / speed)
120  const corona = 2.6 + Math.sin(t / (isWorking ? 260 : 900)) * 0.5
121  for (let i = 0; i < 10; i++) {
122    const a = (i / 10) * TAU + t / 2400
123    if (noise(i, phase) < 0.3) continue
124    dot(cx + Math.cos(a) * corona * 1.9, cy + Math.sin(a) * corona, mix(KZ.amber, DIM, 0.35))
125  }
126  const sunGlyph = isWorking ? '✶✷✸✹✸✷'.charAt(phase % 6) : '✺'
127  put(cx, cy - 0.5, sunGlyph, isWorking ? mix(KZ.yellow, KZ.amber, 0.5 + 0.5 * Math.sin(t / 180)) : KZ.amber, true)
128
129  // The finished, settled on the outer ring by spawn order.
130  for (const a of agents) {
131    if (a.status === 'run') continue
132    const ang = a.order * 2.39996 + 0.6
133    const x = cx + Math.cos(ang) * outerRx
134    const y = cy + Math.sin(ang) * outerRy
135    if (a.status === 'fail') {
136      const fresh = t - (a.end ?? 0) < 3000
137      const on = !fresh || Math.floor(t / 180) % 2 === 0
138      put(x, y, '✖', fresh ? (on ? KZ.red : mix(KZ.red, DIM, 0.6)) : mix(KZ.red, DIM, 0.55), fresh && on)
139    } else {
140      put(x, y, '•', mix(agentColor(a.order), DIM, 0.55))
141    }
142  }
143
144  // The running, each on its lane, with a comet tail.
145  running.sort((a, b) => a.order - b.order).forEach((a, i) => {
146    const { rx, ry } = orbit(i % lanes)
147    const ang = angleAt(a, snapNow, t)
148    const c = agentColor(a.order)
149    for (let j = 1; j <= 4; j++) {
150      const ta = ang - j * 0.16 * Math.sign(a.omega || 1)
151      dot(cx + Math.cos(ta) * rx, cy + Math.sin(ta) * ry, mix(c, DIM, j * 0.18))
152    }
153    put(cx + Math.cos(ang) * rx, cy + Math.sin(ang) * ry, planetGlyph(a.type), c, true)
154  })
155
156  return cells.map((line, y) => {
157    const segs: Seg[] = []
158    for (let x = 0; x < W; x++) {
159      const cell = line[x]
160      const b = bits[y]![x]!
161      const ch = cell ? cell.ch : b ? String.fromCharCode(0x2800 + b) : ' '
162      const c = cell ? cell.c : b ? bitColor[y]![x] : undefined
163      const bold = cell?.b
164      const last = segs[segs.length - 1]
165      if (last && last.c === c && last.b === bold) last.s += ch
166      else segs.push({ s: ch, c, b: bold })
167    }
168    return segs
169  })
170}
171
172/** The legend's lines: a header and up to `rows - 1` agents, each fitted to `w` columns. */
173export function legendLines(agents: readonly Agent[], now: number, w: number, rows: number): Seg[][] {
174  const running = agents.filter(a => a.status === 'run').length
175  const done = agents.filter(a => a.status === 'done').length
176  const failed = agents.filter(a => a.status === 'fail').length
177  const head: Seg[] = [
178    { s: 'orrery ', c: KZ.amber, b: true },
179    { s: `${running} orbiting`, c: running ? KZ.violet : undefined, d: !running },
180    { s: ` · ${done} done`, d: true },
181  ]
182  if (failed) head.push({ s: ` · ${failed} failed`, c: KZ.red })
183  const lines: Seg[][] = [head]
184  for (const a of legendAgents(agents, Math.max(0, rows - 1))) {
185    const c = agentColor(a.order)
186    const mark = a.status === 'run' ? planetGlyph(a.type) : a.status === 'fail' ? '✖' : '✓'
187    const tail = `${a.type} · ${fmtClock(elapsedOf(a, now))}${a.status === 'run' && a.tps >= 1 ? ` · ${Math.round(a.tps)} t/s` : ''}`
188    const room = Math.max(4, w - tail.length - 4)
189    lines.push([
190      { s: `${mark} `, c: a.status === 'fail' ? KZ.red : a.status === 'run' ? c : mix(c, DIM, 0.5), b: a.status === 'run' },
191      { s: clip(a.desc || a.type, room).padEnd(Math.min(room, Math.max(4, (a.desc || a.type).length))), d: a.status !== 'run' },
192      { s: `  ${tail}`, d: true },
193    ])
194  }
195  return lines
196}
197
198/** The desktop drawing: a dark sky with dotted orbits, a pulsing sun, planets on CSS orbits, and a legend. */
199export function orrerySvg(agents: readonly Agent[], now: number, sunTps: number, isWorking: boolean, W: number, H: number): string {
200  const FW = Math.round(Math.min(W * 0.48, 380))
201  const cx = FW / 2
202  const cy = H / 2
203  const outerRx = FW / 2 - 14
204  const outerRy = H / 2 - 9
205  const k = outerRy / outerRx
206  const running = agents.filter(a => a.status === 'run').sort((a, b) => a.order - b.order)
207  const lanes = Math.max(1, Math.min(3, running.length))
208  const laneRx = (i: number) => outerRx * (0.32 + 0.6 * ((i + 1) / (lanes + 1)))
209  const parts: string[] = []
210  const css: string[] = [
211    `.orsky{fill:#0b0e1a}@media (prefers-color-scheme: dark){.orsky{fill:#070912}}`,
212    `.orring{fill:none;stroke:#8b93a7;stroke-opacity:.35;stroke-width:1.2;stroke-dasharray:1 5;stroke-linecap:round}`,
213    `.orouter{fill:none;stroke:#8b93a7;stroke-opacity:.22;stroke-width:1;stroke-dasharray:1 3;stroke-linecap:round;transform-origin:${cx}px ${cy}px;animation:orturn 120s linear infinite}`,
214    `@keyframes orturn{to{transform:rotate(360deg)}}`,
215    `.orbit{transform-origin:0 0;animation:orspin linear infinite}`,
216    `.orbitr{transform-origin:0 0;animation:orspin linear infinite reverse}`,
217    `@keyframes orspin{to{transform:rotate(360deg)}}`,
218    `.orsun{transform-box:fill-box;transform-origin:center;animation:orsun ${isWorking ? Math.max(0.7, 2.2 - sunTps / 60).toFixed(2) : 4}s ease-in-out infinite}`,
219    `@keyframes orsun{50%{transform:scale(1.18);opacity:.85}}`,
220    `.ortw{animation:ortw 3s ease-in-out infinite}@keyframes ortw{50%{opacity:.15}}`,
221    `.orflash{animation:orfl .36s steps(2) 8}@keyframes orfl{50%{opacity:.1}}`,
222  ]
223  parts.push(`<defs>`,
224    `<radialGradient id="orSunG"><stop offset="0" stop-color="#fff7d6"/><stop offset=".35" stop-color="${KZ.yellow}"/><stop offset=".75" stop-color="${KZ.amber}" stop-opacity=".55"/><stop offset="1" stop-color="${KZ.amber}" stop-opacity="0"/></radialGradient>`,
225    `<filter id="orGlow" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="2.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`,
226    `</defs>`)
227  parts.push(`<rect class="orsky" x="0" y="0" width="${FW}" height="${H}" rx="12"/>`)
228  // Stars, seeded so they stay put across redraws.
229  for (let i = 0; i < Math.round(FW / 9); i++) {
230    const sx = noise(i, 1) * FW
231    const sy = noise(i, 2) * H
232    const r = 0.4 + noise(i, 3) * 0.8
233    parts.push(`<circle cx="${sx.toFixed(1)}" cy="${sy.toFixed(1)}" r="${r.toFixed(2)}" fill="#e2e8f0" opacity="${(0.25 + noise(i, 4) * 0.5).toFixed(2)}"${noise(i, 5) > 0.7 ? ` class="ortw" style="animation-delay:-${(noise(i, 6) * 3).toFixed(2)}s"` : ''}/>`)
234  }
235  parts.push(`<ellipse class="orouter" cx="${cx}" cy="${cy}" rx="${outerRx}" ry="${outerRy}"/>`)
236  for (let i = 0; i < lanes; i++) parts.push(`<ellipse class="orring" cx="${cx}" cy="${cy}" rx="${laneRx(i).toFixed(1)}" ry="${(laneRx(i) * k).toFixed(1)}"/>`)
237
238  // The sun.
239  parts.push(`<circle cx="${cx}" cy="${cy}" r="${(H * 0.2).toFixed(1)}" fill="url(#orSunG)" opacity=".55" class="orsun"/>`)
240  parts.push(`<circle cx="${cx}" cy="${cy}" r="${(H * 0.075).toFixed(1)}" fill="url(#orSunG)" filter="url(#orGlow)"/>`)
241
242  // The finished on the outer ring.
243  for (const a of agents) {
244    if (a.status === 'run') continue
245    const ang = a.order * 2.39996 + 0.6
246    const x = cx + Math.cos(ang) * outerRx
247    const y = cy + Math.sin(ang) * outerRy
248    if (a.status === 'fail') {
249      const fresh = now - (a.end ?? 0) < 4000
250      parts.push(`<text x="${x.toFixed(1)}" y="${(y + 4).toFixed(1)}" font-size="11" text-anchor="middle" fill="${KZ.red}" opacity="${fresh ? 1 : 0.45}"${fresh ? ' class="orflash"' : ''} font-family="sans-serif">✖</text>`)
251    } else {
252      parts.push(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="2.2" fill="${agentColor(a.order)}" opacity=".45"/>`)
253    }
254  }
255
256  // The running: rotate a lane around the sun, counter-rotate the planet so it
257  // stays round under the ellipse's squash. A negative delay starts it where it is.
258  running.forEach((a, i) => {
259    const rx = laneRx(i % lanes)
260    const c = agentColor(a.order)
261    const period = Math.max(1.2, Math.min(60, TAU / Math.max(0.05, a.omega)))
262    const ang = (((angleAt(a, now, now) % TAU) + TAU) % TAU)
263    const delay = `-${((ang / TAU) * period).toFixed(2)}s`
264    const style = `animation-duration:${period.toFixed(2)}s;animation-delay:${delay}`
265    const tail = [0.14, 0.28, 0.42, 0.56].map((d, j) => `<circle cx="${(Math.cos(-d) * rx).toFixed(1)}" cy="${(Math.sin(-d) * rx).toFixed(1)}" r="${(2.6 - j * 0.5).toFixed(1)}" fill="${c}" opacity="${(0.5 - j * 0.11).toFixed(2)}"/>`).join('')
266    parts.push(
267      `<g transform="translate(${cx} ${cy}) scale(1 ${k.toFixed(4)})"><g class="orbit" style="${style}">${tail}`,
268      `<g transform="translate(${rx.toFixed(1)} 0)"><g class="orbitr" style="${style}"><g transform="scale(1 ${(1 / k).toFixed(4)})">`,
269      `<circle r="${(4.2 + Math.min(2, a.tps / 60)).toFixed(1)}" fill="${c}" filter="url(#orGlow)"/>`,
270      `</g></g></g></g></g>`,
271    )
272  })
273
274  // Legend, on the theme's panel.
275  const LX = FW + 10
276  const LW = W - LX
277  parts.push(`<rect class="p" x="${LX}" y="0" width="${LW}" height="${H}" rx="12"/>`)
278  const nRun = running.length
279  const nDone = agents.filter(a => a.status === 'done').length
280  const nFail = agents.filter(a => a.status === 'fail').length
281  parts.push(svgText(LX + 12, 18, 'ORRERY', { size: 10, weight: 700, fill: KZ.amber }))
282  parts.push(svgText(LX + 64, 18, `${nRun} orbiting · ${nDone} done${nFail ? ` · ${nFail} failed` : ''}`, { cls: 's', size: 10.5 }))
283  const list = legendAgents(agents, 4)
284  const lh = (H - 28) / 4
285  list.forEach((a, i) => {
286    const y = 28 + i * lh + lh / 2 + 4
287    const c = agentColor(a.order)
288    if (a.status === 'run') parts.push(`<circle cx="${LX + 17}" cy="${y - 4}" r="4" fill="${c}" class="pulse"/>`)
289    else parts.push(svgText(LX + 17, y, a.status === 'fail' ? '✖' : '✓', { size: 11, anchor: 'middle', fill: a.status === 'fail' ? KZ.red : c }))
290    const right = `${a.type} · ${fmtClock(elapsedOf(a, now))}${a.status === 'run' && a.tps >= 1 ? ` · ${Math.round(a.tps)} t/s` : ''}`
291    const rightW = right.length * 6
292    parts.push(svgText(LX + 30, y, fitText(a.desc || a.type, 12, Math.max(40, LW - 52 - rightW)), { cls: a.status === 'run' ? 't' : 's', size: 12, weight: a.status === 'run' ? 600 : 400 }))
293    parts.push(svgText(W - 12, y, right, { cls: 'm', size: 10.5, anchor: 'end' }))
294  })
295  return svg(W, H, parts.join(''), css.join('\n'))
296}
297
298export function orreryAlt(agents: readonly Agent[]): string {
299  const run = agents.filter(a => a.status === 'run')
300  return `Subagent orrery: ${run.length} running${run.length ? ` (${run.map(a => a.desc || a.type).join(', ')})` : ''}, ${agents.filter(a => a.status === 'done').length} done, ${agents.filter(a => a.status === 'fail').length} failed.`
301}
302
303
hooks/scene.tsx 59 lines
1// Orrery's terminal scene: planets on dotted orbits around a flickering sun,
2// redrawn at 30 fps, and the legend beside it. Runs in the drawing surface.
3import type { ClientModule } from 'claude-code'
4
5import { fieldFrame, legendLines } from './sky.ts'
6import type { Agent, Seg } from './sky.ts'
7
8type P = { agents: Agent[]; now: number; sunTps: number; isWorking: boolean; width: number; rows: number }
9type S = { t: number; sig: number; at: number }
10
11const FRAME = 33
12
13const Scene: ClientModule<P, S> = (props, surface) => {
14  const s = surface.state
15  if (s === undefined) {
16    surface.setState({ t: 0, sig: props.now, at: 0 })
17    surface.every(FRAME, () => {
18      const c = surface.state
19      if (c) surface.setState({ ...c, t: c.t + 1 })
20    })
21  } else if (s.sig !== props.now) {
22    surface.setState({ ...s, sig: props.now, at: s.t })
23  }
24  const t = s?.t ?? 0
25  const at = s && s.sig === props.now ? s.at : t
26  const now = props.now + (t - at) * FRAME
27
28  const { Box, Text } = surface.elements
29  const width = surface.columns > 0 ? surface.columns : props.width
30  const rows = Math.max(3, props.rows)
31  const fieldW = Math.max(16, Math.min(64, Math.round(width * 0.42)))
32  const legendW = Math.max(10, width - fieldW - 3)
33  const field = fieldFrame(props.agents, props.now, now, fieldW, rows, props.sunTps, props.isWorking)
34  const legend = legendLines(props.agents, now, legendW, rows)
35  const line = (segs: readonly Seg[], key: string) => (
36    <Text key={key} wrap="truncate-end">
37      {segs.map((g, i) => (
38        <Text key={`s${i}`} color={g.c} bold={g.b} dimColor={g.d}>{g.s}</Text>
39      ))}
40    </Text>
41  )
42
43  return (
44    <Box flexDirection="row">
45      <Box flexDirection="column" width={fieldW} flexShrink={0}>
46        {field.map((segs, y) => line(segs, `f${y}`))}
47      </Box>
48      <Box flexDirection="column" width={3} flexShrink={0}>
49        {field.map((_, y) => <Text key={`g${y}`} dimColor> │</Text>)}
50      </Box>
51      <Box flexDirection="column" width={legendW} flexShrink={0}>
52        {legend.map((segs, y) => line(segs, `l${y}`))}
53      </Box>
54    </Box>
55  )
56}
57
58export default Scene
59
types/index.d.ts 37 lines
1export type OrbitStatus = 'run' | 'done' | 'fail'
2
3/** One subagent as a planet. */
4export type OrbitAgent = {
5  id: string
6  desc: string
7  type: string
8  model: string
9  /** Spawn order in the session, from 0: picks the orbit and the color. */
10  order: number
11  status: OrbitStatus
12  start: number
13  end: number | null
14  /** Output tokens per second over the last 20 s. */
15  tps: number
16  tokens: number
17  /** Where the planet is on its orbit at `now` (radians), and its angular speed (rad/s). */
18  angle: number
19  omega: number
20}
21
22export type OrrerySnap = {
23  now: number
24  agents: OrbitAgent[]
25  /** The main loop's own output tokens per second: how bright the sun burns. */
26  sunTps: number
27  isWorking: boolean
28  /** When the last agent finished, 0 while none has. */
29  lastEnd: number
30}
31
32declare module 'claude-code' {
33  interface PluginState {
34    orrery: { snap: OrrerySnap | null; isHidden: boolean }
35  }
36}
37