SLOPSHOPPER

superlite-agents

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

newpaneguardcommandtoasttool
★ 235v0.1.0MITupdated 2026-10-26iha2/superlite/mods/agent-map
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · superlite-agents
│ ┃ Agent map ✕ › fix the failing auth test and add an audit log call │ ┃ Agent map 0 agents · 0 active │ ┃ ● Main session opus · 0 subagents · 0 active ⏺ Read(src/auth.ts) │ ┃ └─ no subagents yet ⎿ 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 │ │ › /superlite-agents │ ⎿ superlite-agents: Agent map opened (no .superlite records here; │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent map
Agent map 0 agents · 0 active ● Main session opus · 0 subagents · 0 active └─ no subagents yet
README

<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">


Use it

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.

Say what you want

SituationSay something likeWhat 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

Or use a command

CommandWhat 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:approveChoose who orchestrates; approve the review plan
/superlite:status, /superlite:stop, /superlite:resumeWatch, stop or resume a build
/superlite:agents, /superlite:tailWatch a run: the agent map pane, or the same run as a terminal feed without the mod
/superlite:review, /superlite:report, /superlite:commit, /superlite:finishReview 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.

Who orchestrates

  • Human orchestrator. You get a plain-language review plan with diagrams (a .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.
  • AI orchestrator. It plans, lints, dispatches and rules on open choices itself, with no review plan and no questions.

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.

Watch a run

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.
  • Without the mod (a terminal session, another harness), /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).

Install

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.


What it is

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.

  • Autonomous. Describe the project, pick the AI orchestrator, and walk away. Decisions it has to make are recorded as rulings, not asked as questions.
  • Fast. Up to 18 agents at once (measured in run k1; the dispatch cap is now 10 running tasks), a plan written for parallelism, and mechanical work on Haiku.

Not a rival to superpowers

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.


It is fast

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 measuredsuperpowerssuperlite
Wall-clock to a finished build33.6 min (stock, 3 runs: 21.2 / 31.9 / 47.6)<br>23.5 min (orchestrated, 3 runs)16.1 min
Faster than superpowers52% vs stock, 31% vs orchestrated
Fastest-to-slowest spread, 3 runs26.4 min9.1 min
Agents working at once3 to 4up to 18 (k1)
Hidden tests passed177 / 177177 / 177 (a tie, at higher speed)
Earlier 9-module benchmark (1 run each)34.4 min8.8 min
superlite against its own first version26.9 min16.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>


More

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.


Configuration

  • 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 review. The coordinator runs at medium effort. The final review is a fresh Opus 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.
  • Implementer cache TTL. Set 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:.
  • Context-budgeted sizing. Each task in a plan carries an 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

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.

Claude Code

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

Antigravity

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.

Codex App

Unverified: superpowers ships on Codex through the official Codex marketplace, which superlite isn't listed in. This repo includes .agents/plugins/marketplace.json for a repo marketplace, but the command below hasn't been tested yet.

  • Add this repository as a plugin marketplace:
  codex plugin marketplace add iha2/superlite
  • In the Codex app, click on Plugins in the sidebar, find Superlite in the Coding section, and click the + next to it.
  • Add [features] multi_agent = true to ~/.codex/config.toml for subagent dispatch.

Codex CLI

Unverified: superpowers ships on Codex through the official Codex marketplace, which superlite isn't listed in. This repo includes .agents/plugins/marketplace.json for a repo marketplace, but the command below hasn't been tested yet.

  • Add this repository as a plugin marketplace:
  codex plugin marketplace add iha2/superlite
  • Open the plugin search interface:
  /plugins
  • Search for superlite and select Install Plugin.

Cursor

  • In Cursor Agent chat, install from marketplace:
  /add-plugin superlite
  • Or search for "superlite" in the plugin marketplace.
  • Or load a local checkout of https://github.com/iha2/superlite as a local plugin; Cursor reads .cursor-plugin/plugin.json.

Devin CLI

  • Install the plugin from this repository:
  devin plugins install iha2/superlite
  • Update to the latest version with:
  devin plugins update superlite

Factory Droid

  • Register the marketplace:
  droid plugin marketplace add https://github.com/iha2/superlite
  • Install the plugin:
  droid plugin install superlite@superlite-dev

Gemini CLI

  • Install the extension:
  gemini extensions install https://github.com/iha2/superlite
  • Update later:
  gemini extensions update superlite

GitHub Copilot CLI

  • Register the marketplace:
  copilot plugin marketplace add iha2/superlite
  • Install the plugin:
  copilot plugin install superlite@superlite-dev

Grok Build CLI

Unverified: superpowers ships on Grok through the official xAI marketplace, which superlite isn't listed in. The commands below haven't been tested yet.

  • Register this repository as a marketplace:
  grok plugin marketplace add iha2/superlite
  • Install the plugin:
  grok plugin install superlite@superlite-dev --trust
  • Or open the marketplace in the TUI, search for Superlite, and install it:
  /marketplace

Kimi Code

  • Open Kimi Code's plugin manager:
  /plugins
  • Install directly from this repository:
  /plugins install https://github.com/iha2/superlite

OpenCode

OpenCode uses its own plugin install; install superlite separately even if you already use it in another harness.

  • Tell OpenCode:
  Fetch and follow instructions from https://raw.githubusercontent.com/iha2/superlite/refs/heads/main/.opencode/INSTALL.md

Pi

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

Qwen Code installs plugins from Claude Code marketplaces directly.

  • Install the plugin from this repository:
  qwen extensions install iha2/superlite
  • Update later:
  qwen extensions update superlite

Hermes Agent

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.

Muse

Superlite is a native Muse plugin. The using-superlite bootstrap is injected via the native SessionStart hook, with no per-session opt-in.

  • Install from a local checkout:
  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
  • Update later:
  muse plugins update superlite

Restart any active Muse sessions after installing so the SessionStart hook takes effect; hooks require approval on first install.

Keeping versions in sync

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

Benchmark

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.1superlite 0.1.0 (the version benchmarked)
Wall-clock34.4 min8.8 min (3.9× faster)
Hidden tests89 / 8989 / 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.)

Running it

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.

Tasks

  • ☐ The task-graph flow (planned as 0.3, shipped in 1.0): reach half of superpowers' time. The design is in docs/specs/2026-10-17-superlite-architecture-design.md (D10-D18, G1, R1). Steps:
  • Smoke-test the 0.3 tools and agents.
  • Combined task (bench/tasks/combo), 3 reps, arms in parallel.
  • E12: planted defects (bench/tasks/combo-planted), 3 reps with and without the incident log.
  • E13: brownfield (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.

Note

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.

License

MIT

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