Read-only agent map pane: the main session and each subagent with its tier, time, tokens and superlite tasks

<img src="assets/superlite-logo.svg" width="160" alt="superlite: a feather made of neurons">
<h1 align="center">superlite</h1>
<b>Hand an agent a spec. Come back to finished, tested work.</b><br> An autonomous, feather-light build workflow for Claude Code.
<img alt="license" src="https://img.shields.io/badge/license-MIT-6366f1"> <img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude%20Code-plugin-2dd4bf"> <img alt="parallel agents" src="https://img.shields.io/badge/parallel%20agents-up%20to%2018-c084fc">
Install it (below), open a session in your project, and say what you want. superlite shows a one-screen intro the first time; /superlite:intro brings it back and /superlite:help has every detail.
| Situation | Say something like | What happens |
|---|---|---|
| MVP, a new project | "Build me a static site generator from this spec" | One batch of questions, a plan, then parallel tasks on Sonnet and Haiku, package reviews and one fresh final review |
| A feature on existing code | "Add CSV export with column widths to the existing app" | Feature mode: it researches the code (Graphify on a large codebase, never installed without your yes), writes a feature plan you validate, builds a small prototype in its own worktree for you to try, then ports it into your code with frozen interfaces and seam checks. The report shows both builds |
| Debug | "This test fails on leap days: find the root cause and fix it" | diagnosis finds the cause first; fixes go to fix-only agents by tier, each leaving a note, and escalate after two failed rounds |
| A small change | "Rename build to render everywhere" | Small work stays inline, with a reviewer always |
| Review and harden | "Review this branch and fix what you find" | Package reviewers fix their own findings; the final reviewer reports findings with tiers and never edits |
| You approve first | "Build X. I will approve the plan" | A human orchestrator: you get a plain-language review plan with diagrams; nothing runs until you say yes |
| Hands off | "Build X. I am away, decide yourself" | The AI orchestrator plans, lints, dispatches and rules on open choices without stopping |
| Ship it | "We are done: finish the branch" | Verification first, then merge or PR options. It never pushes without you |
| Command | What it does |
|---|---|
/superlite:start [mode] [what] | A menu: superlite suggests the mode for your request (regular, MVP, feature, debug, small, review), you confirm it and pick who orchestrates |
/superlite:mvp <what> | Plan the smallest working version on a clickable HTML prototype, then build it |
/superlite:feature <change> | Change existing code: a plan you validate, a prototype you try, then the integration with seam checks |
/superlite:debug <bug> | Root cause first, then a fix with a regression test |
/superlite:small <change> | A small change inline, without the planning flow |
/superlite:human, /superlite:ai, /superlite:approve | Choose who orchestrates; approve the review plan |
/superlite:status, /superlite:stop, /superlite:resume | Watch, stop or resume a build |
/superlite:agents, /superlite:tail | Watch a run: the agent map pane, or the same run as a terminal feed without the mod |
/superlite:review, /superlite:report, /superlite:commit, /superlite:finish | Review a branch, read build reports, turn checkpoints into commits, finish the branch |
/superlite:setup, /superlite:intro, /superlite:help [section] | First-run check, the intro screen, the detailed help |
Plain words work just as well; the commands are shortcuts.
.review.md, about two pages, with a rough time and cost), and nothing is linted, dispatched or sent to an agent until you reply approve. After that you are not asked again: any change to scope is recorded as a ruling and listed at the end under "Changed from the approved plan". Small inline work needs only your yes in chat.Both stop for destructive, outward (push, merge, publish) or security-sensitive actions, and nothing is committed unless you ask: finished tasks are kept as hidden checkpoints until you run /superlite:commit. A checkpoint is a hidden save of one task under refs/superlite/*; a commit is a real git commit on your branch, made only by /superlite:commit or sl autocommit on.
Let it run. Watch .superlite/ in your project: progress.md has the rulings and the log, test-summary.json the latest test result, metrics.jsonl and the run history the time and cost per agent.
flowchart LR
A([Your request]) --> B[Plan]
B --> C[Build in parallel]
C --> D[Review and fix]
D --> E([Done])
<img src="assets/agent-map.webp" width="720" alt="The agent map pane: the main session at the top, the coordinator under it, each implementer, reviewer and fixer under the agent that spawned it, with tier, time, tokens and tasks; a blocked agent in red. Below it, the live feed of agent starts, finishes and ledger lines.">
A read-only view, offered when you approve the plan (reply approve map), or opened any time:
/superlite:agents opens the agent map in Claude Desktop: a tree from the main session down, each agent under the one that spawned it, green while it runs, red if it was aborted or blocked, grey when done. Each node shows its tier, time, tokens and tasks (✓ once checkpointed); click a row for its details and its ledger lines. It needs the superlite-agents mod: /plugin install superlite-agents@superlite-dev./superlite:tail prints the same run as a live feed: every agent start and finish from metrics.jsonl and every ledger line from progress.md (sl tail --dir .superlite).You need Claude Code, git, Python 3.10 or newer (the tools are Python; the project you build can be in any language), and bash (Git Bash on Windows). uv is optional.
/plugin marketplace add iha2/superlite
/plugin install superlite@superlite-dev
Or load a local checkout for one session: claude --plugin-dir <path-to-superlite>
Open a fresh session: the superlite intro appears once. Then send Let's make a react todo list; a working install starts inventing before any code is written. Installs for Codex, Cursor, Gemini CLI, OpenCode and the other harnesses are in Installation below.
superlite plans a build, splits it into tasks that can run side by side, hands each task to the cheapest agent that can do it well, checks the seams, and reviews the result. You decide who orchestrates: you, or the AI.
superlite is not a competitor to superpowers. superpowers is a careful, collaborative workflow that keeps a human close to every step, and it is excellent at that. superlite starts from its ideas (the plugin layout, the session-start hook, the lifecycle skills, with credit) and tunes them for a different job: autonomous development, at speed. If you want to steer each step, use superpowers. If you want to hand over a spec and come back to working code, use superlite. A superlite run suspends superpowers until sl finish; after that, superpowers can be used again.
Same machine, same task: build a Python site generator and a spreadsheet engine from one spec (sitegen + minisheet, 177 hidden tests), headless on Opus.
| What we measured | superpowers | superlite |
|---|---|---|
| Wall-clock to a finished build | 33.6 min (stock, 3 runs: 21.2 / 31.9 / 47.6)<br>23.5 min (orchestrated, 3 runs) | 16.1 min |
| Faster than superpowers | 52% vs stock, 31% vs orchestrated | |
| Fastest-to-slowest spread, 3 runs | 26.4 min | 9.1 min |
| Agents working at once | 3 to 4 | up to 18 (k1) |
| Hidden tests passed | 177 / 177 | 177 / 177 (a tie, at higher speed) |
| Earlier 9-module benchmark (1 run each) | 34.4 min | 8.8 min |
| superlite against its own first version | 26.9 min | 16.1 min (40% faster) |
And it is not tied to one language. A TypeScript site generator went from spec to 132 / 132 hidden tests in 8.5 minutes.
<sub>The 16.1 min figure is one run of the current build. The superpowers figures, the spread row (3 runs of each, on an earlier superlite build) and the 9-module row (the first version, 1 run each) come from earlier runs on the same machine, so treat the gaps as indications, not a statistical proof. superlite can cost more than superpowers and scores lower on the extra robustness probes (4 of 7 against about 5.7 of 7). Details, runs and caveats: bench/ and the Benchmark section below.</sub>
How it works, in docs/: how a run works · skills and agents · hooks · tools · config · language support · how the rules are enforced · an example review plan. Installation for other harnesses, the benchmark harness and the task list follow below.
detail (plan switch). drafting records Detail: contracts | tests | full in the plan header, and a task can override it. contracts gives files, signatures and named tests with exact values. tests (the default) adds the test code verbatim and leaves the implementation to the implementer. full includes implementation code too, for tricky algorithms. inventing asks for it in its single batched question, and picks the default itself when unattended.final-reviewer at high effort: one turn, findings only, reading a review packet built from the logs, with a target under 30 s. It is not a fork, so CLAUDE_CODE_FORK_SUBAGENT is not needed.CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL=1h in the environment to keep an implementer's history cached when fix rounds arrive waves later. The agent files no longer set it: a nested experimental: block in agent frontmatter made Claude Code ignore model:.Est. context line, predicted from the lines it writes, the lines it reads and the docs it is handed, on top of the implementer's fixed ~18k prefix. Tasks predicted above 80k are split, tasks whose variable part is smaller than the prefix are flagged by lint with a NOTE to merge, tasks sit in a 1.5 to 6 minute band (predictor.json), there is no fixed wave size (the task graph sets the parallel width), and the whole plan stays under 15k chars because it doubles as the shared prime. The coefficients are fit on one project, so treat them as a guide.Installation differs by harness. If you use more than one, install superlite separately for each one. Every integration loads using-superlite at session start; to verify, open a fresh session and send Let's make a react todo list. A working install triggers inventing before any code is written.
Each harness has a tool-mapping file in skills/using-superlite/references/ (Antigravity, Codex, Gemini, Hermes, Muse, Pi, plus Claude Code notes). Where a harness has no plugin agents, each agent in agents/ (implementer, implementer-haiku, package-reviewer, final-reviewer and the three fixers) runs as that harness's generic subagent with agents/<name>.md as its instructions.
Load for one session from a local checkout:
claude --plugin-dir <path-to-superlite>
Or add a local checkout as a marketplace:
/plugin marketplace add <path-to-superlite> /plugin install superlite@superlite-dev
Or install from GitHub:
/plugin marketplace add iha2/superlite /plugin install superlite@superlite-dev
Install superlite as a plugin from this repository:
agy plugin install https://github.com/iha2/superlite
Antigravity runs the plugin's session-start hook, so superlite is active from the first message. Reinstall with the same command to update.
Unverified: superpowers ships on Codex through the official Codex marketplace, which superlite isn't listed in. This repo includes
.agents/plugins/marketplace.jsonfor a repo marketplace, but the command below hasn't been tested yet.
codex plugin marketplace add iha2/superlite
Superlite in the Coding section, and click the + next to it.[features] multi_agent = true to ~/.codex/config.toml for subagent dispatch.Unverified: superpowers ships on Codex through the official Codex marketplace, which superlite isn't listed in. This repo includes
.agents/plugins/marketplace.jsonfor a repo marketplace, but the command below hasn't been tested yet.
codex plugin marketplace add iha2/superlite
/plugins
superlite and select Install Plugin. /add-plugin superlite
https://github.com/iha2/superlite as a local plugin; Cursor reads .cursor-plugin/plugin.json. devin plugins install iha2/superlite
devin plugins update superlite
droid plugin marketplace add https://github.com/iha2/superlite
droid plugin install superlite@superlite-dev
gemini extensions install https://github.com/iha2/superlite
gemini extensions update superlite
copilot plugin marketplace add iha2/superlite
copilot plugin install superlite@superlite-dev
Unverified: superpowers ships on Grok through the official xAI marketplace, which superlite isn't listed in. The commands below haven't been tested yet.
grok plugin marketplace add iha2/superlite
grok plugin install superlite@superlite-dev --trust
/marketplace
/plugins
/plugins install https://github.com/iha2/superlite
/new afterwards.OpenCode uses its own plugin install; install superlite separately even if you already use it in another harness.
Fetch and follow instructions from https://raw.githubusercontent.com/iha2/superlite/refs/heads/main/.opencode/INSTALL.md
Install superlite as a Pi package from this repository:
pi install git:github.com/iha2/superlite
For local development, run Pi with a checkout loaded as a temporary package:
pi -e /path/to/superlite
The Pi package loads the superlite skills and a small extension that injects the using-superlite bootstrap at session startup and again after compaction. Subagent and task-list tools remain optional Pi companion packages.
Qwen Code installs plugins from Claude Code marketplaces directly.
qwen extensions install iha2/superlite
qwen extensions update superlite
Install superlite as a Hermes plugin from this repository:
hermes plugins install iha2/superlite --enable
Restart any active Hermes sessions after installing. Hermes has no post-compaction hook, so a very long session that compacts over its first turn loses the bootstrap; start a fresh session if skills stop triggering.
Superlite is a native Muse plugin. The using-superlite bootstrap is injected via the native SessionStart hook, with no per-session opt-in.
muse plugins install ./
muse plugins approve superlite
Or clone and install:
git clone https://github.com/iha2/superlite.git
muse plugins install ./superlite
muse plugins approve superlite
muse plugins update superlite
Restart any active Muse sessions after installing so the SessionStart hook takes effect; hooks require approval on first install.
The version lives in every manifest listed in .version-bump.json. Bump all of them with one command, and check for drift with --check or --audit:
scripts/bump-version.sh 0.2.0
scripts/bump-version.sh --check
bench/ holds a head-to-head harness: both plugins build the same 9-module project (bench/task/SPEC.md) headless on Opus, and a hidden 89-test suite scores the result. Run 1 (n=1 per arm; the runs overlapped):
| superpowers 6.4.1 | superlite 0.1.0 (the version benchmarked) | |
|---|---|---|
| Wall-clock | 34.4 min | 8.8 min (3.9× faster) |
| Hidden tests | 89 / 89 | 89 / 89 |
| Cost (API-equivalent) | $8.15 | $3.28 (60% cheaper) |
Full write-up: docs/benchmark-report.md. (An earlier version of these numbers overstated both costs because of a scoring bug; see the correction note in the report.)
From the repo root. harness.py runs one arm headless and scores it against the hidden suite; BENCH_TASK picks a task from bench/tasks/ (combo, combo-planted, brownfield, ts-wordstat, ts-textkit, ts-logflow, ts-logflow-plus, ts-sitegen).
uv run --no-project python bench/harness.py run <arm> <rep> [--smoke] uv run --no-project python bench/harness.py score <arm> <rep> uv run --no-project python bench/harness.py rescore-tasks <arm> <rep> uv run --no-project python bench/harness.py report
Arms are superpowers and superlite. Environment variables: SUPERLITE_PLUGIN_DIR points the superlite arm at another plugin directory (for example a worktree of an older commit, as a frozen baseline); BENCH_PROMPT_SUFFIX appends text to the task prompt.
Analysis scripts:
python bench/report_mechanical.py bench/results/combo-superlite-x1.mechanical.json bench/results/*.json python bench/report_mechanical.py --history ~/.superlite/history.jsonl [--focus opus,haiku] [--baseline sonnet] python bench/mechanical_ab.py RUN_DIR [--tasks T8,T5] [--n 3] [--cost-cap 2.0] [--timeout 300] [--dry-run] [--wait] python bench/routing_evidence.py [--write] python bench/build_shape_audit.py [--runs DIR] [--write] python bench/predict_calibration.py [--write]
report_mechanical compares tasks by agent and model. mechanical_ab replays Kind: mechanical tasks on Haiku and on the Sonnet implementer. routing_evidence gives per-band outcomes by model and a routing recommendation. build_shape_audit shows what each task graph costs and which edges could go. predict_calibration shows why predicted times miss and fits a model on real runs.
docs/specs/2026-10-17-superlite-architecture-design.md (D10-D18, G1, R1). Steps:bench/tasks/combo), 3 reps, arms in parallel.bench/tasks/combo-planted), 3 reps with and without the incident log.bench/tasks/brownfield), 3 reps with and without Graphify.Done when the superlite median is at most 0.5x superpowers' on the combined task (G1) at equal hidden-test score and robustness of at least 6/7, and the E12 and E13 effects are measured.
While a superlite run is in progress, superpowers is suspended (sl orchestrator set writes .superlite/peers.json and, on Claude Code, turns superpowers@claude-plugins-official off in .claude/settings.local.json). The lifecycle skills are named inventing, drafting, swarm, testing, diagnosis, proof, and shipping, so a short-name lookup does not open the superpowers file. Both plugins still inject a session bootstrap. sl finish removes the marker and restores the previous setting, so superpowers or anything like it can be used again.
MIT
hooks/register.tsx 404 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LiveAgent, Superlite, Usage } from '../types'
5import {
6 elapsedMs,
7 emptySuperlite,
8 fileActive,
9 fmtDuration,
10 fmtTokens,
11 ledgerFor,
12 mergeCards,
13 parseLedger,
14 parseMetrics,
15 parsePool,
16 subagentTranscript,
17 tierOf,
18 totalTokens,
19 transcriptUsage,
20} from './model'
21import type { Card, Status } from './model'
22import { graphSvg, treeOrder } from './graph'
23import type { GraphNode } from './graph'
24
25const PANE = 'superlite-agents'
26const TITLE = 'Agent map'
27const TOOL = 'open_agent_map'
28
29const live = atom({ plugin: 'superlite-agents', key: 'live' } as const, {})
30const usage = atom({ plugin: 'superlite-agents', key: 'usage' } as const, {})
31const files = atom({ plugin: 'superlite-agents', key: 'files' } as const, emptySuperlite())
32const selected = atom({ plugin: 'superlite-agents', key: 'selected' } as const, null)
33const now = atom({ plugin: 'superlite-agents', key: 'now' } as const, 0)
34const mainActive = atom({ plugin: 'superlite-agents', key: 'mainActive' } as const, false)
35const mainModel = atom({ plugin: 'superlite-agents', key: 'mainModel' } as const, '')
36
37type $ = EngineInterface
38
39// Each transcript's mtime when last read: read again only when it moved.
40const readAt = new Map<string, number>()
41
42async function upsert($: $, id: string, fn: (prev: LiveAgent | undefined) => LiveAgent): Promise<void> {
43 await update($, live, (all: Record<string, LiveAgent> | undefined) => ({ ...(all ?? {}), [id]: fn(all?.[id]) }))
44}
45
46async function markStarted($: $, id: string, fields: Partial<LiveAgent>): Promise<void> {
47 const t = await $.clock.now()
48 await upsert($, id, prev => ({
49 id,
50 type: fields.type || prev?.type || '',
51 description: fields.description || prev?.description || '',
52 model: fields.model ?? prev?.model,
53 startedAt: prev?.startedAt ?? t,
54 stoppedAt: undefined,
55 isActive: true,
56 isAborted: false,
57 parent: fields.parent ?? prev?.parent,
58 transcript: fields.transcript ?? prev?.transcript,
59 last: prev?.last,
60 }))
61 await update($, now, () => t)
62}
63
64async function markStopped($: $, id: string, fields: Partial<LiveAgent>): Promise<void> {
65 const t = await $.clock.now()
66 await upsert($, id, prev => ({
67 id,
68 type: fields.type || prev?.type || '',
69 description: prev?.description || '',
70 model: prev?.model,
71 startedAt: prev?.startedAt,
72 stoppedAt: t,
73 isActive: false,
74 isAborted: fields.isAborted ?? prev?.isAborted,
75 parent: prev?.parent,
76 transcript: fields.transcript ?? prev?.transcript,
77 last: fields.last ?? prev?.last,
78 }))
79 await update($, now, () => t)
80}
81
82async function readText($: $, path: string): Promise<string | undefined> {
83 try {
84 return await $.fs.read(path)
85 } catch {
86 return undefined
87 }
88}
89
90/** Re-reads the .superlite records and the transcripts not yet final; every read may fail, none throws. */
91async function refresh($: $): Promise<void> {
92 const dir = (await read($, files)).dir
93 const fresh: Superlite = emptySuperlite(dir)
94 const metrics = await readText($, `${dir}/metrics.jsonl`)
95 if (metrics !== undefined) Object.assign(fresh, { hasMetrics: true }, parseMetrics(metrics))
96 const pool = await readText($, `${dir}/pool.json`)
97 if (pool !== undefined) fresh.pool = parsePool(pool)
98 const ledger = await readText($, `${dir}/progress.md`)
99 if (ledger !== undefined) Object.assign(fresh, parseLedger(ledger))
100
101 try {
102 for (const a of await $.agent.list()) {
103 const running = a.status === 'running' || a.status === 'pending' || a.status === 'waiting'
104 const known = (await read($, live))[a.id]
105 const failed = a.status === 'failed' || a.status === 'killed'
106 if (known && known.isActive === running && (running || !!known.isAborted === failed)) continue
107 if (running) await markStarted($, a.id, { type: a.type, description: a.description, parent: a.parentId })
108 else if (known) await markStopped($, a.id, { isAborted: failed })
109 }
110 } catch {
111 // no agent list on this host: the events alone
112 }
113
114 const agents = await read($, live)
115 const got: Record<string, Usage> = {}
116 const mtimes: Record<string, number> = {}
117 for (const id of new Set([...Object.keys(agents), ...Object.keys(fresh.stops)])) {
118 const path = agents[id]?.transcript ?? fresh.stops[id]?.transcript
119 if (!path) continue
120 let mtime: number
121 try {
122 mtime = (await $.fs.stat(path)).mtimeMs
123 } catch {
124 continue
125 }
126 mtimes[id] = mtime
127 if (readAt.get(id) === mtime) continue
128 const text = await readText($, path)
129 if (text === undefined) continue
130 got[id] = transcriptUsage(text)
131 readAt.set(id, mtime)
132 }
133 fresh.active = fileActive(fresh, mtimes, await $.clock.now())
134 await update($, files, () => fresh)
135 if (Object.keys(got).length) await update($, usage, (all: Record<string, Usage> | undefined) => ({ ...(all ?? {}), ...got }))
136}
137
138async function openPane($: $): Promise<void> {
139 const r = await $.ui.open({ id: PANE, title: TITLE })
140 if (!r.isPlaced) await $.ui.toast(`Agent map is open but no surface shows it now: ${JSON.stringify(r)}`)
141}
142
143function dirOf(args: string): string | undefined {
144 const a = args.trim().replace(/^["']|["']$/g, '').replace(/[\\/]+$/, '')
145 if (!a) return undefined
146 return /(^|[\\/])\.superlite$/.test(a) ? a : `${a}/.superlite`
147}
148
149/** Points the map at a project (or its .superlite folder) when given one, refreshes it and opens the pane. */
150async function show($: $, args: string): Promise<string> {
151 const dir = dirOf(args)
152 if (dir) {
153 readAt.clear()
154 await update($, usage, () => ({}))
155 await update($, files, () => emptySuperlite(dir))
156 await $.store.set('dir', dir).catch(() => undefined)
157 }
158 await update($, selected, () => null)
159 const t = await $.clock.now()
160 await update($, now, () => t)
161 await refresh($).catch(() => undefined)
162 await openPane($)
163 const f = await read($, files)
164
165 return f.hasMetrics ? `Agent map opened (${f.dir}).` : 'Agent map opened (no .superlite records here; live events only).'
166}
167
168export const register: Register = on => {
169 on('session.start', async ($, e, next) => {
170 await $.command.register({
171 name: 'superlite-agents',
172 description: 'Show the agent map: the main session and each subagent, read-only',
173 argumentHint: '[project dir]',
174 immediate: true,
175 })
176 await $.tool.register({
177 name: TOOL,
178 description:
179 'Opens the read-only agent map pane beside the transcript: the main session and each subagent with its tier, time, tokens, tasks and ledger lines. Call it when the user asks to watch a superlite run. dir: the project folder (or its .superlite folder); omit for the working directory.',
180 inputSchema: { type: 'object', properties: { dir: { type: 'string' } } },
181 isDeferred: false,
182 }).catch(() => undefined)
183 try {
184 const model = await $.session.model()
185 await update($, mainModel, () => model)
186 } catch {
187 // no model yet
188 }
189 const kept = await $.store.get('dir').catch(() => undefined)
190 if (typeof kept === 'string' && kept) await update($, files, (f: Superlite | undefined) => ({ ...(f ?? emptySuperlite()), dir: kept }))
191 void refresh($).catch(() => undefined)
192
193 let ticks = 0
194 $.clock.every(1000, () => {
195 ticks += 1
196 void (async () => {
197 const agents = await read($, live)
198 const busy =
199 (await read($, mainActive)) || Object.values(agents).some(a => a.isActive) || (await read($, files)).active.length > 0
200 if (busy) {
201 const t = await $.clock.now()
202 await update($, now, () => t)
203 }
204 if (ticks % 3 === 0) await refresh($)
205 })().catch(() => undefined)
206 })
207
208 return next(e)
209 })
210
211 on('command.run', { command: 'superlite-agents' }, async ($, e) => ({ text: await show($, e.args) }))
212
213 on('tool.call', { tool: 'mcp__superlite-agents__open_agent_map' }, async ($, e) => {
214 const dir = (e as { dir?: unknown }).dir
215 const text = await show($, typeof dir === 'string' ? dir : '')
216
217 return { result: text, text, isReadOnly: true as const }
218 })
219
220 on('agent.spawn', async ($, e, next) => {
221 const ran = await next(e)
222 try {
223 if (ran.agentId)
224 await markStarted($, ran.agentId, { type: e.subagentType, description: e.description, model: ran.model, parent: e.parentAgentId })
225 } catch {
226 // the map is a view: never in the spawn's way
227 }
228
229 return ran
230 }).catch(($, e, next) => next(e))
231
232 on('classic.SubagentStart', async ($, e, next) => {
233 await markStarted($, e.agent_id, { type: e.agent_type, transcript: subagentTranscript(e.transcript_path, e.agent_id) }).catch(
234 () => undefined,
235 )
236
237 return next(e)
238 })
239
240 on('classic.SubagentStop', async ($, e, next) => {
241 await markStopped($, e.agent_id, {
242 type: e.agent_type,
243 transcript: e.agent_transcript_path || undefined,
244 last: e.last_assistant_message,
245 }).catch(() => undefined)
246 readAt.delete(e.agent_id)
247 void refresh($).catch(() => undefined)
248
249 return next(e)
250 })
251
252 on('turn.start', async ($, e, next) => {
253 if (!e.agentId) await update($, mainActive, () => true).catch(() => undefined)
254
255 return next(e)
256 })
257
258 on('turn.complete', async ($, e, next) => {
259 const done = await next(e)
260 if (!e.agentId) await update($, mainActive, () => false).catch(() => undefined)
261 else if (e.isAborted || e.reason === 'aborted' || e.reason === 'error')
262 await markStopped($, e.agentId, { isAborted: true }).catch(() => undefined)
263
264 return done
265 })
266
267 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
268 const { Box, Text, Button } = $.ui.resolve(e)
269 const cards = mergeCards(await read($, live), await read($, files), await read($, usage))
270 const at = (await read($, now)) || Date.now()
271 const pick = await read($, selected)
272 const f = await read($, files)
273 const isMainActive = await read($, mainActive)
274 const model = await read($, mainModel)
275 const cols = e.props.bodyColumns
276 const active = cards.filter(c => c.isActive).length
277 const tone = { running: 'success', aborted: 'error', done: 'inactive' } as const
278 const dot = (st: Status) => <Text color={tone[st]}>●</Text>
279 const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s)
280 const shortType = (t: string) => t.split(':').pop() || '-'
281 const cardWidth = Math.max(24, Math.min(36, Math.floor(((cols || 80) - 28) / 2) - 1))
282
283 const head = (
284 <Text>
285 <Text weight="semibold">{TITLE}</Text>
286 <Text dimColor>
287 {' '}
288 {cards.length} agents · {active} active{f.hasMetrics ? ` · ${f.dir}` : ''}
289 </Text>
290 </Text>
291 )
292
293 const card = pick ? cards.find(c => c.id === pick) : undefined
294 if (card) {
295 const t = card.usage?.tokens
296 const rows: [string, string][] = [
297 ['Agent', card.id],
298 ['Type', card.type || '-'],
299 ['Model', card.model ?? '-'],
300 ['Tier', card.tier ?? '-'],
301 ['Tasks', card.tasks.join(' ') || '-'],
302 ['Status', `${card.status}${card.isKnownLive ? '' : ' (from the run files)'}`],
303 ['Parent', card.parent ? (cards.find(c => c.id === card.parent)?.description ?? card.parent) : 'main session'],
304 ['Started', card.startedAt ? new Date(card.startedAt).toISOString() : '-'],
305 ['Elapsed', fmtDuration(elapsedMs(card, at))],
306 ['Tokens', t ? `${totalTokens(t)} (in ${t.input}, out ${t.output}, cache read ${t.cacheRead}, cache write ${t.cacheWrite})` : '-'],
307 ['Tool calls', card.usage ? String(card.usage.toolCalls) : '-'],
308 ['Transcript', card.transcript ?? '-'],
309 ]
310
311 return (
312 <Box flexDirection="column">
313 {head}
314 <Button key="back" plain onPress={() => update($, selected, () => null)}>
315 ‹ Back to map
316 </Button>
317 <Text>
318 {dot(card.status)} <Text weight="semibold">{card.description || card.type || card.id}</Text>
319 </Text>
320 {rows.map(([k, v]) => (
321 <Text>
322 <Text dimColor>{k.padEnd(11)}</Text>
323 {v}
324 </Text>
325 ))}
326 {ledgerFor(f.ledger, card).length ? <Text dimColor>Ledger</Text> : null}
327 {ledgerFor(f.ledger, card)
328 .slice(-12)
329 .map(l => (
330 <Text>{l}</Text>
331 ))}
332 {card.last ? <Text dimColor>Last message</Text> : null}
333 {card.last ? <Text>{card.last.slice(0, 1200)}</Text> : null}
334 </Box>
335 )
336 }
337
338 const metaOf = (c: Card) => `${c.tier ?? '?'} · ${fmtDuration(elapsedMs(c, at))} · ${fmtTokens(c.usage ? totalTokens(c.usage.tokens) : undefined)}`
339 const tasksOf = (c: Card) =>
340 c.tasks.length ? c.tasks.map(t => (c.doneTasks.includes(t) ? `${t}✓` : t)).join(' ') : shortType(c.type)
341 const mainMeta = `${tierOf(model) ?? (model || '-')} · ${cards.length} subagents · ${active} active`
342 const ledgerRows = [
343 f.ledger.length > 0 ? <Text dimColor>Ledger (progress.md)</Text> : null,
344 ...f.ledger.slice(-8).map(l => <Text>{l}</Text>),
345 ]
346
347 const mainStatus: Status = isMainActive || active > 0 ? 'running' : 'done'
348 const nodes: GraphNode[] = cards.map(c => ({
349 id: c.id,
350 parent: c.parent,
351 title: c.description || c.type || c.id,
352 meta: metaOf(c),
353 tasks: tasksOf(c),
354 status: c.status,
355 type: c.type,
356 }))
357 const main = { title: 'Main session', meta: mainMeta, status: mainStatus }
358
359 const byId = new Map(cards.map(c => [c.id, c]))
360 const tree = [
361 <Text>
362 {dot(mainStatus)} <Text weight="semibold">Main session</Text> <Text dimColor>{mainMeta}</Text>
363 </Text>,
364 ...(cards.length === 0 ? [<Text dimColor>└─ no subagents yet</Text>] : []),
365 ...treeOrder(main, nodes).map(row => {
366 const c = byId.get(row.id)!
367 const lead = row.trail.map(more => (more ? '│ ' : ' ')).join('') + (row.isLast ? '└─ ' : '├─ ')
368 return (
369 <Box key={`box-${c.id}`} flexDirection="row">
370 <Text dimColor>{lead}</Text>
371 <Button key={`card-${c.id}`} plain onPress={() => update($, selected, () => c.id)}>
372 {dot(c.status)} {clip(c.description || c.type || c.id, Math.max(16, cardWidth))}{' '}
373 <Text dimColor>
374 {metaOf(c)} · {tasksOf(c)}
375 </Text>
376 </Button>
377 </Box>
378 )
379 }),
380 ]
381
382 if (e.surface !== 'terminal') {
383 const { Svg } = $.ui.resolve(e)
384 const g = graphSvg(main, nodes, Math.max(420, (cols || 110) * 8))
385 return (
386 <Box flexDirection="column" gap={1}>
387 {head}
388 <Svg source={g.source} alt={`Agent map: main session and ${cards.length} subagents, ${active} running`} width={g.width} height={g.height} isInteractive />
389 <Box flexDirection="column">{tree}</Box>
390 <Box flexDirection="column">{ledgerRows}</Box>
391 </Box>
392 )
393 }
394
395 return (
396 <Box flexDirection="column">
397 {head}
398 {tree}
399 {ledgerRows}
400 </Box>
401 )
402 })
403}
404hooks/model.ts 320 lines1// Pure parsing and merging for the agent map: no `$`, so tests call it directly.
2// Token counting follows skills/swarm/scripts/metrics.py
3// (transcript_segments): usage per assistant message, deduplicated by message id
4// keeping the largest value per field, output topped up from the text length.
5import type { LiveAgent, MetricsLaunch, MetricsStop, PoolEntry, Superlite, Tokens, Usage } from '../types'
6
7const FAMILIES = ['haiku', 'sonnet', 'opus'] as const
8const TID = /\bT(\d+[a-z]?)\b/
9const TIDS = /\bT(\d+[a-z]?)\b/g
10const CHARS_PER_TOKEN = 3.5
11// a stop whose last words say the agent did not finish its work
12const ABORTED = /\bStatus:\s*\**\s*BLOCKED\b|\[Request interrupted|\binterrupted by user\b|\baborted\b/i
13
14export type Tier = (typeof FAMILIES)[number]
15
16/** running: green; aborted: red; done: grey. */
17export type Status = 'running' | 'aborted' | 'done'
18
19export type Card = {
20 id: string
21 type: string
22 description: string
23 model?: string
24 tier?: Tier
25 tasks: string[]
26 doneTasks: string[]
27 startedAt?: number
28 stoppedAt?: number
29 isActive: boolean
30 status: Status
31 /** The agent that spawned it; absent when the main session did. */
32 parent?: string
33 isKnownLive: boolean
34 transcript?: string
35 last?: string
36 usage?: Usage
37}
38
39type Obj = Record<string, unknown>
40
41const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
42const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
43
44export function parseJsonl(text: string): Obj[] {
45 const out: Obj[] = []
46 for (const line of text.split(/\r?\n/)) {
47 if (!line.trim()) continue
48 try {
49 const v = JSON.parse(line)
50 if (v && typeof v === 'object' && !Array.isArray(v)) out.push(v as Obj)
51 } catch {
52 // a torn last line while the hooks append: skip it
53 }
54 }
55 return out
56}
57
58export function emptySuperlite(dir = '.superlite'): Superlite {
59 return { dir, hasMetrics: false, launches: {}, stops: {}, pool: {}, active: [], ledger: [], sent: {}, done: [] }
60}
61
62/** Launch and stop rows by agent id: the first launch, the last stop (as metrics.py joins them). */
63export function parseMetrics(text: string): Pick<Superlite, 'launches' | 'stops'> {
64 const launches: Record<string, MetricsLaunch> = {}
65 const stops: Record<string, MetricsStop> = {}
66 for (const e of parseJsonl(text)) {
67 const id = str(e.agent_id)
68 if (!id) continue
69 if (e.ev === 'launch' && !launches[id]) {
70 launches[id] = { id, t: num(e.t), type: str(e.type), model: str(e.model), desc: str(e.desc), parent: str(e.parent) }
71 } else if (e.ev === 'stop') {
72 const prev = stops[id]
73 stops[id] = {
74 id,
75 t: num(e.t) ?? prev?.t,
76 first: prev?.first ?? num(e.t),
77 type: str(e.type) ?? prev?.type,
78 transcript: str(e.transcript) ?? prev?.transcript,
79 last: str(e.last) ?? prev?.last,
80 }
81 }
82 }
83 return { launches, stops }
84}
85
86export function parsePool(text: string): Record<string, PoolEntry> {
87 const out: Record<string, PoolEntry> = {}
88 let doc: unknown
89 try {
90 doc = JSON.parse(text)
91 } catch {
92 return out
93 }
94 const agents = doc && typeof doc === 'object' ? (doc as Obj).agents : undefined
95 if (!Array.isArray(agents)) return out
96 for (const a of agents) {
97 if (!a || typeof a !== 'object') continue
98 const id = (a as Obj).id
99 if (id === undefined || id === null) continue
100 const tasks = (a as Obj).tasks
101 out[String(id)] = {
102 tasks: Array.isArray(tasks) ? tasks.filter((t): t is string => typeof t === 'string') : [],
103 tier: str((a as Obj).tier),
104 current: str((a as Obj).current),
105 }
106 }
107 return out
108}
109
110const SENT = /\bsent\s+(?:R:)?(T\d+[a-z]?(?:\s*[+,]\s*T\d+[a-z]?)*)\s+to\s+(\S+)/i
111const CHECKPOINT = /\bcheckpoint\s+(T\d+[a-z]?)\b/i
112
113/** progress.md: its lines, the tasks sent to each agent, the tasks checkpointed. */
114export function parseLedger(text: string): Pick<Superlite, 'ledger' | 'sent' | 'done'> {
115 const ledger: string[] = []
116 const sent: Record<string, string[]> = {}
117 const done: string[] = []
118 for (const raw of text.split(/\r?\n/)) {
119 const line = raw.trim().replace(/^[-*]\s+/, '')
120 if (!line || line.startsWith('#')) continue
121 ledger.push(line)
122 const s = SENT.exec(line)
123 if (s) {
124 const list = (sent[s[2]!] ??= [])
125 for (const t of s[1]!.split(/\s*[+,]\s*/)) if (!list.includes(t)) list.push(t)
126 }
127 const c = CHECKPOINT.exec(line)
128 if (c && !done.includes(c[1]!)) done.push(c[1]!)
129 }
130 return { ledger, sent, done }
131}
132
133/** The ledger lines about one agent: its id, or one of its tasks as a word. */
134export function ledgerFor(ledger: string[], card: Pick<Card, 'id' | 'tasks'>): string[] {
135 const words = card.tasks.map(t => new RegExp(`\\b${t}\\b`))
136 return ledger.filter(l => l.includes(card.id) || words.some(w => w.test(l)))
137}
138
139function msgChars(content: unknown): number {
140 if (!Array.isArray(content)) return 0
141 let n = 0
142 for (const b of content) {
143 if (!b || typeof b !== 'object') continue
144 const o = b as Obj
145 n += (str(o.text)?.length ?? 0) + (str(o.thinking)?.length ?? 0)
146 if (o.type === 'tool_use') n += JSON.stringify(o.input ?? {}).length
147 }
148 return n
149}
150
151/** One agent transcript (JSONL) to its usage. */
152export function transcriptUsage(text: string): Usage {
153 const tokens: Tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
154 const seen = new Map<string, { rec: Tokens; chars: number }>()
155 const calls = new Set<string>()
156 let firstMs: number | undefined
157 let lastMs: number | undefined
158 let model: string | undefined
159 for (const e of parseJsonl(text)) {
160 if (e.type !== 'assistant') continue
161 const msg = (e.message && typeof e.message === 'object' ? e.message : {}) as Obj
162 const ts = str(e.timestamp)
163 const t = ts ? Date.parse(ts) : NaN
164 if (Number.isFinite(t)) {
165 firstMs = firstMs ?? t
166 lastMs = t
167 }
168 model = str(msg.model) ?? model
169 const mid = str(msg.id) ?? `anon${seen.size}`
170 const u = (msg.usage && typeof msg.usage === 'object' ? msg.usage : {}) as Obj
171 const rec: Tokens = {
172 input: num(u.input_tokens) ?? 0,
173 output: num(u.output_tokens) ?? 0,
174 cacheRead: num(u.cache_read_input_tokens) ?? 0,
175 cacheWrite: num(u.cache_creation_input_tokens) ?? 0,
176 }
177 const prev = seen.get(mid)
178 if (prev) {
179 prev.chars += msgChars(msg.content)
180 for (const k of Object.keys(rec) as (keyof Tokens)[]) {
181 if (rec[k] > prev.rec[k]) {
182 tokens[k] += rec[k] - prev.rec[k]
183 prev.rec[k] = rec[k]
184 }
185 }
186 } else {
187 seen.set(mid, { rec: { ...rec }, chars: msgChars(msg.content) })
188 for (const k of Object.keys(rec) as (keyof Tokens)[]) tokens[k] += rec[k]
189 }
190 if (Array.isArray(msg.content)) {
191 for (const b of msg.content) {
192 if (b && typeof b === 'object' && (b as Obj).type === 'tool_use') calls.add(str((b as Obj).id) ?? `${mid}:${calls.size}`)
193 }
194 }
195 }
196 for (const v of seen.values()) {
197 const est = Math.floor(v.chars / CHARS_PER_TOKEN)
198 if (est > v.rec.output) tokens.output += est - v.rec.output
199 }
200 return { tokens, toolCalls: calls.size, firstMs, lastMs, model }
201}
202
203export function totalTokens(t: Tokens): number {
204 return t.input + t.output + t.cacheRead + t.cacheWrite
205}
206
207export function tierOf(...hints: (string | undefined)[]): Tier | undefined {
208 for (const h of hints) {
209 const m = (h ?? '').toLowerCase()
210 const f = FAMILIES.find(x => m.includes(x))
211 if (f) return f
212 }
213 return undefined
214}
215
216/** Where a subagent's transcript lives, from the main session's: `<session>/subagents/agent-<id>.jsonl`. */
217export function subagentTranscript(mainTranscript: string | undefined, id: string): string | undefined {
218 if (!mainTranscript || !mainTranscript.endsWith('.jsonl')) return undefined
219 return `${mainTranscript.slice(0, -'.jsonl'.length)}/subagents/agent-${id}.jsonl`
220}
221
222/**
223 * Every agent this session started, and every agent the superlite records name,
224 * as one card each, oldest first. Live status is the engine's events where this
225 * session saw the agent, else what the files show (`fileActive`).
226 */
227export function mergeCards(live: Record<string, LiveAgent>, files: Superlite, usage: Record<string, Usage>): Card[] {
228 const ids = new Set([...Object.keys(live), ...Object.keys(files.launches), ...Object.keys(files.stops)])
229 const cards: Card[] = []
230 for (const id of ids) {
231 const l = live[id]
232 const la = files.launches[id]
233 const st = files.stops[id]
234 const pool = files.pool[id]
235 const u = usage[id]
236 const description = l?.description || la?.desc || ''
237 const fromDesc = [...description.matchAll(TIDS)].map(m => `T${m[1]}`)
238 const fromLedger = files.sent[id] ?? []
239 // pool.current is the task in flight, or a batch "T3,T5" (several tasks per agent message)
240 const inFlight = (pool?.current ?? '').split(',').map(t => t.trim()).filter(t => TID.test(t))
241 const known = [...new Set([...(pool?.tasks ?? []), ...inFlight, ...fromLedger])]
242 const tasks = known.length ? known : [...new Set(fromDesc)]
243 const model = l?.model ?? la?.model ?? u?.model
244 const type = l?.type || la?.type || st?.type || ''
245 const isActive = l ? l.isActive : files.active.includes(id)
246 const last = l?.last ?? st?.last
247 const status: Status = isActive
248 ? 'running'
249 : l?.isAborted || ABORTED.test(last ?? '') || (!l && !st && la?.t !== undefined)
250 ? 'aborted'
251 : 'done'
252 cards.push({
253 id,
254 type,
255 description,
256 model,
257 tier: tierOf(pool?.tier, model, type),
258 tasks,
259 doneTasks: tasks.filter(t => files.done.includes(t)),
260 startedAt: l?.startedAt ?? earliest(la?.t, st?.first, u?.firstMs),
261 stoppedAt: l ? l.stoppedAt : (st?.t ?? u?.lastMs),
262 isActive,
263 status,
264 parent: l?.parent ?? la?.parent,
265 isKnownLive: l !== undefined,
266 transcript: l?.transcript ?? st?.transcript,
267 last,
268 usage: u,
269 })
270 }
271 return cards.sort((a, b) => (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity) || a.id.localeCompare(b.id))
272}
273
274const FRESH_MS = 20_000
275const LAUNCH_MS = 15 * 60_000
276
277/**
278 * Agents at work by the files alone: launched and not stopped since (within 15
279 * minutes of the launch), or whose transcript changed in the last 20 seconds.
280 */
281export function fileActive(files: Pick<Superlite, 'launches' | 'stops'>, mtimes: Record<string, number>, now: number): string[] {
282 const ids = new Set([...Object.keys(files.launches), ...Object.keys(files.stops), ...Object.keys(mtimes)])
283 return [...ids].filter(id => {
284 const la = files.launches[id]
285 const st = files.stops[id]
286 const m = mtimes[id]
287 if (m !== undefined && now - m < FRESH_MS) return true
288 return la?.t !== undefined && !st && now - la.t < LAUNCH_MS
289 })
290}
291
292// superlite writes an agent's launch row when its Agent call returns, after the prime turn's stop.
293function earliest(...ts: (number | undefined)[]): number | undefined {
294 const known = ts.filter((t): t is number => t !== undefined)
295 return known.length ? Math.min(...known) : undefined
296}
297
298/** Elapsed time: to now while active, to the stop otherwise; undefined when the start is unknown. */
299export function elapsedMs(card: Card, now: number): number | undefined {
300 if (card.startedAt === undefined) return undefined
301 const end = card.isActive ? now : (card.stoppedAt ?? card.usage?.lastMs)
302 return end === undefined ? undefined : Math.max(0, end - card.startedAt)
303}
304
305export function fmtDuration(ms: number | undefined): string {
306 if (ms === undefined) return '-'
307 const s = Math.round(ms / 1000)
308 if (s < 60) return `${s}s`
309 const m = Math.floor(s / 60)
310 if (m < 60) return `${m}m ${s % 60}s`
311 return `${Math.floor(m / 60)}h ${m % 60}m`
312}
313
314export function fmtTokens(n: number | undefined): string {
315 if (n === undefined) return '- tok'
316 if (n < 1000) return `${n} tok`
317 if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k tok`
318 return `${(n / 1_000_000).toFixed(2)}M tok`
319}
320hooks/graph.ts 192 lines1// The agent map as an SVG tree, top to bottom: the main session at the top, each
2// agent under the agent that spawned it. Children hang off a trunk on their
3// parent's left and wrap into rows to fit the pane. Pure: a string out.
4import type { Status } from './model'
5
6export type GraphNode = {
7 id: string
8 parent?: string
9 title: string
10 meta: string
11 tasks: string
12 status: Status
13 /** The agent type (`superlite:implementer`, `Explore`, ...): picks the edge style. */
14 type?: string
15}
16
17export type GraphMain = { title: string; meta: string; status: Status }
18
19const NODE_W = 216
20const NODE_H = 58
21const GAP_X = 14
22const GAP_Y = 44
23const INDENT = 30
24const TRUNK = 14
25const PAD = 8
26
27export const COLORS: Record<Status, string> = { running: '#22a35a', aborted: '#d1242f', done: '#9aa0a6' }
28const LINE = '#b8bec4'
29
30export type Role = 'coordinator' | 'implementer' | 'reviewer' | 'fixer' | 'final' | 'other'
31
32/** One edge style per kind of agent: color, width, dash (none is solid). */
33export const ROLES: Record<Role, { label: string; color: string; width: number; dash?: string }> = {
34 coordinator: { label: 'coordinator', color: '#8250df', width: 2.6 },
35 implementer: { label: 'implementer', color: '#0969da', width: 1.8 },
36 reviewer: { label: 'reviewer', color: '#bf8700', width: 1.8, dash: '6 4' },
37 fixer: { label: 'fixer', color: '#e16f24', width: 2, dash: '2 3' },
38 final: { label: 'final review', color: '#1a7f75', width: 2, dash: '10 3 2 3' },
39 other: { label: 'other', color: '#8c959f', width: 1.5 },
40}
41
42export function roleOf(type: string | undefined): Role {
43 const t = (type ?? '').toLowerCase()
44 if (t.includes('final-review')) return 'final'
45 if (t.includes('coordinator')) return 'coordinator'
46 if (t.includes('fixer')) return 'fixer'
47 if (t.includes('review')) return 'reviewer'
48 if (t.includes('implementer')) return 'implementer'
49 return 'other'
50}
51
52const LEGEND_H = 26
53const INK = '#1f2328'
54const DIM = '#59636e'
55const CARD = '#f6f8fa'
56
57export function esc(s: string): string {
58 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
59}
60
61function clip(s: string, n: number): string {
62 return s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s
63}
64
65type Item = { id: string; title: string; meta: string; tasks: string; status: Status; type?: string; kids: Item[] }
66type Laid = { item: Item; w: number; h: number; rows: { kids: Laid[]; w: number; h: number }[] }
67
68/** Children by parent; a parent the list does not hold is the main session. */
69export function buildTree(main: GraphMain, nodes: GraphNode[]): Item {
70 const ids = new Set(nodes.map(n => n.id))
71 const items = new Map<string, Item>(nodes.map(n => [n.id, { ...n, kids: [] }]))
72 const root: Item = { id: 'main', ...main, tasks: '', kids: [] }
73 for (const n of nodes) {
74 const up = n.parent && n.parent !== n.id && ids.has(n.parent) ? items.get(n.parent)! : root
75 up.kids.push(items.get(n.id)!)
76 }
77 // a parent cycle in the records would orphan its members: hang them off the main session
78 const seen = new Set<string>()
79 const walk = (i: Item) => {
80 seen.add(i.id)
81 i.kids.forEach(walk)
82 }
83 walk(root)
84 for (const n of nodes) {
85 if (seen.has(n.id)) continue
86 const orphan = items.get(n.id)!
87 for (const i of items.values()) i.kids = i.kids.filter(k => k !== orphan)
88 root.kids.push(orphan)
89 walk(orphan)
90 }
91 return root
92}
93
94function lay(item: Item, maxW: number): Laid {
95 const kids = item.kids.map(k => lay(k, Math.max(NODE_W, maxW - INDENT)))
96 const rows: Laid['rows'] = []
97 for (const k of kids) {
98 const row = rows[rows.length - 1]
99 if (row && row.w + GAP_X + k.w <= maxW - INDENT) {
100 row.kids.push(k)
101 row.w += GAP_X + k.w
102 row.h = Math.max(row.h, k.h)
103 } else rows.push({ kids: [k], w: k.w, h: k.h })
104 }
105 const w = Math.max(NODE_W, ...rows.map(r => INDENT + r.w))
106 const h = NODE_H + rows.reduce((s, r) => s + GAP_Y + r.h, 0)
107 return { item, w, h, rows }
108}
109
110function box(x: number, y: number, n: Item, out: string[]): void {
111 const color = COLORS[n.status]
112 const run = n.status === 'running'
113 const chars = Math.floor((NODE_W - 34) / 6.3)
114 out.push(
115 `<g><title>${esc([n.title, n.meta, n.tasks, n.status, n.id === 'main' ? '' : n.id].filter(Boolean).join('\n'))}</title>`,
116 `<rect x="${x}" y="${y}" width="${NODE_W}" height="${NODE_H}" rx="9" fill="${CARD}" stroke="${color}" stroke-width="${run || n.status === 'aborted' ? 2 : 1}"/>`,
117 n.id === 'main' ? '' : `<rect x="${x + 1.5}" y="${y + 9}" width="3" height="${NODE_H - 18}" rx="1.5" fill="${ROLES[roleOf(n.type)].color}"/>`,
118 `<circle cx="${x + 14}" cy="${y + 17}" r="5" fill="${color}">${run ? '<animate attributeName="opacity" values="1;0.35;1" dur="1.4s" repeatCount="indefinite"/>' : ''}</circle>`,
119 `<text x="${x + 26}" y="${y + 21}" font-size="12.5" font-weight="600" fill="${INK}">${esc(clip(n.title, chars))}</text>`,
120 `<text x="${x + 12}" y="${y + 38}" font-size="11" fill="${DIM}">${esc(clip(n.meta, chars + 3))}</text>`,
121 n.tasks ? `<text x="${x + 12}" y="${y + 52}" font-size="11" fill="${DIM}">${esc(clip(n.tasks, chars + 3))}</text>` : '',
122 '</g>',
123 )
124}
125
126function draw(l: Laid, x: number, y: number, edges: string[], boxes: string[]): void {
127 box(x, y, l.item, boxes)
128 if (!l.rows.length) return
129 const tx = x + TRUNK
130 let top = y + NODE_H + GAP_Y
131 let lastBus = 0
132 for (const row of l.rows) {
133 const bus = top - GAP_Y / 2
134 lastBus = bus
135 let cx = x + INDENT
136 const centers: number[] = []
137 for (const k of row.kids) {
138 const kc = cx + NODE_W / 2
139 centers.push(kc)
140 const r = ROLES[roleOf(k.item.type)]
141 const run = k.item.status === 'running'
142 const faded = k.item.status === 'done' ? ' opacity="0.55"' : ''
143 edges.push(
144 `<path d="M${kc} ${bus} L${kc} ${top}" fill="none" stroke="${r.color}" stroke-width="${r.width + (run ? 0.6 : 0)}"${
145 r.dash ? ` stroke-dasharray="${r.dash}"` : ''
146 }${faded} data-role="${roleOf(k.item.type)}"${
147 run ? '><animate attributeName="opacity" values="1;0.4;1" dur="1.2s" repeatCount="indefinite"/></path>' : '/>'
148 }`,
149 )
150 draw(k, cx, top, edges, boxes)
151 cx += k.w + GAP_X
152 }
153 edges.push(`<path d="M${tx} ${bus} L${Math.max(...centers)} ${bus}" fill="none" stroke="${LINE}" stroke-width="1.3"/>`)
154 top += row.h + GAP_Y
155 }
156 edges.push(`<path d="M${tx} ${y + NODE_H} L${tx} ${lastBus}" fill="none" stroke="${LINE}" stroke-width="1.3"/>`)
157}
158
159/** The tree's SVG document and its size in CSS pixels, laid out to `maxWidth`. */
160export function graphSvg(main: GraphMain, nodes: GraphNode[], maxWidth = 900): { source: string; width: number; height: number } {
161 const laid = lay(buildTree(main, nodes), Math.max(NODE_W + INDENT, maxWidth - 2 * PAD))
162 const used = (Object.keys(ROLES) as Role[]).filter(r => nodes.some(n => roleOf(n.type) === r))
163 const width = Math.max(laid.w + 2 * PAD, PAD + used.length * 118)
164 const height = laid.h + 2 * PAD + (used.length ? LEGEND_H : 0)
165 const edges: string[] = []
166 const boxes: string[] = []
167 draw(laid, PAD, PAD, edges, boxes)
168 const ly = laid.h + 2 * PAD + 12
169 const legend = used
170 .map((r, i) => {
171 const x = PAD + i * 118
172 const st = ROLES[r]
173 return `<path d="M${x} ${ly} L${x + 26} ${ly}" stroke="${st.color}" stroke-width="${st.width}"${st.dash ? ` stroke-dasharray="${st.dash}"` : ''}/><text x="${x + 32}" y="${ly + 4}" font-size="11" fill="${DIM}">${st.label}</text>`
174 })
175 .join('')
176 const source = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" font-family="system-ui, -apple-system, Segoe UI, sans-serif">${edges.join('')}${boxes.join('')}${legend}</svg>`
177 return { source, width, height }
178}
179
180/** Depth-first order with each agent's depth, for the terminal's text tree. */
181export function treeOrder(main: GraphMain, nodes: GraphNode[]): { id: string; depth: number; isLast: boolean; trail: boolean[] }[] {
182 const out: { id: string; depth: number; isLast: boolean; trail: boolean[] }[] = []
183 const walk = (i: Item, depth: number, trail: boolean[]) =>
184 i.kids.forEach((k, j) => {
185 const isLast = j === i.kids.length - 1
186 out.push({ id: k.id, depth, isLast, trail })
187 walk(k, depth + 1, [...trail, !isLast])
188 })
189 walk(buildTree(main, nodes), 0, [])
190 return out
191}
192types/index.d.ts 68 lines1export type Tokens = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6}
7
8/** One subagent as this session's own start and stop events saw it. */
9export type LiveAgent = {
10 id: string
11 type: string
12 description: string
13 model?: string
14 startedAt?: number
15 stoppedAt?: number
16 isActive: boolean
17 /** The engine said its loop failed, was killed or its turn aborted. */
18 isAborted?: boolean
19 /** The agent whose loop spawned it; absent when the main session did. */
20 parent?: string
21 transcript?: string
22 last?: string
23}
24
25/** What an agent's transcript adds: tokens deduplicated by message id, tool calls, first and last message times. */
26export type Usage = {
27 tokens: Tokens
28 toolCalls: number
29 firstMs?: number
30 lastMs?: number
31 model?: string
32}
33
34export type MetricsLaunch = { id: string; t?: number; type?: string; model?: string; desc?: string; parent?: string }
35export type MetricsStop = { id: string; t?: number; first?: number; type?: string; transcript?: string; last?: string }
36export type PoolEntry = { tasks: string[]; tier?: string; current?: string }
37
38/** The project's `.superlite` records, parsed; empty where the files are missing. */
39export type Superlite = {
40 dir: string
41 hasMetrics: boolean
42 launches: Record<string, MetricsLaunch>
43 stops: Record<string, MetricsStop>
44 pool: Record<string, PoolEntry>
45 /** Agents the files show at work (for a run in another process, whose events never reach this session). */
46 active: string[]
47 /** progress.md: the coordinator's ledger lines, oldest first. */
48 ledger: string[]
49 /** Tasks the ledger sent to each agent id (`sent T3+T1 to <id>`). */
50 sent: Record<string, string[]>
51 /** Tasks the ledger checkpointed (`checkpoint T0 <sha> ...`). */
52 done: string[]
53}
54
55declare module 'claude-code' {
56 interface PluginState {
57 'superlite-agents': {
58 live: Record<string, LiveAgent>
59 usage: Record<string, Usage>
60 files: Superlite
61 selected: string | null
62 now: number
63 mainActive: boolean
64 mainModel: string
65 }
66 }
67}
68