Little Planet Factory agents for Claude Code.


A Claude Code plugin marketplace from Little Planet Labs.
| Plugin | What it does |
|---|---|
| Little Planet Factory | A team of agents that plans, delegates, implements, and inspects multi-part work |
| Music Video | Turns a company into a song and a beat-synced music video about it |
/plugin marketplace add Little-Planet-Labs/plugin-cc
/plugin install little-planet-factory@little-planet-labs-cc
To roll it out to everyone working in a repo, add this to the repo's .claude/settings.json. Claude Code prompts teammates to install it when they trust the folder:
{
"extraKnownMarketplaces": {
"little-planet-labs-cc": {
"source": { "source": "github", "repo": "Little-Planet-Labs/plugin-cc" }
}
},
"enabledPlugins": {
"little-planet-factory@little-planet-labs-cc": true
}
}
Six agents that split a task into units of work, run them in parallel where they can't break each other's builds, and don't call it done until it has been checked.
overseer you talk to this one
├── researcher answers one question, with cited findings
├── worker small, well-defined units
├── manager complex sub-tasks
│ ├── researcher
│ ├── worker
│ ├── worker
│ └── inspector reviews the manager's sub-task
├── inspector reviews units and the combined result
└── signoff confirms everything asked for is done
Run Claude Code as the overseer:
claude --agent little-planet-factory:overseer
Or make it the default for a project in .claude/settings.json:
{ "agent": "little-planet-factory:overseer" }
The overseer replaces the default Claude Code system prompt for that session. To run subagents on sonnet instead, see Lite tier.
The overseer keeps a small ledger of its working state in the session scratchpad: the agents it has running, review rounds, and Linear state. The plugin's SessionStart hook tells the overseer where the ledger is and puts it back into context after compaction or when you resume a session, so auto-compaction doesn't make it lose track of that work. Turn on auto-compact in /config for long runs, such as working through a Linear board overnight. The hook needs Node.js, and does nothing if node isn't on your PATH.
In an overseer session the plugin also loads a mod that shows the factory's state inside Claude Code. The mod needs Claude Code 2.1.287 or later, where mods are on by default. Versions without mod support ignore it, and the rest of the plugin keeps working.
×N; descriptions share the width and are cut in the middle when they must be; the rest fold into +N), the ledger's next step, and the open question count. Press 1 at an empty prompt to open /factory (below 26 columns the band leaves the button out, so use /factory there). On a narrow or short terminal it drops the 7-day gauge, then the chips, down to one line. Other mods' rows in the band stay under it./factory. Opens a pane with usage gauges (context, tokens left before auto-compaction, the 5-hour and 7-day rate limits, and session cost), the agent tree from the overseer down through managers to workers (each with its status, model, elapsed time, and tokens, counted as Claude Code counts an agent's tokens), and a summary of the ledger: its status, next step, open questions, units by state, background work, and each manager ledger's next step.The mod only reads the ledgers in the session scratchpad and never writes a file. In any other session it shows nothing and adds nothing to the model's context.
Overseer (little-planet-factory:overseer) is the controller. It scopes the work, breaks it into units that don't touch the same files, and hands them to workers and managers, in parallel only when they don't build together: units where either's build compiles the other's files in one working tree run one after another, and one that stops unfinished holds the rest up until it's done or you decide. It doesn't write code unless you tell it to. It launches agents in the background so you can keep talking to it while they run: ask questions, add work, or redirect an agent mid-task. It owns final quality. Work isn't done until every unit has reported back, it has read every diff, verification passes, every required inspection has come back clean, and it has cleaned up the build output agents reported.
Manager (little-planet-factory:manager) sits between the overseer and a group of workers when a sub-task is too complex to hand to one worker. It decomposes the sub-task, runs it across workers, integrates and verifies the result, runs its own inspection, and reports a summary so the overseer doesn't have to track the detail. It makes low-risk calls itself and lists them as assumptions. It comes back to the overseer with anything that changes scope or a shared interface. In the full tier, it picks opus or sonnet for each worker based on the unit's difficulty and risk. It never uses haiku for a worker, and nothing above opus unless you ask for it.
Researcher (little-planet-factory:researcher) answers one question for the overseer or a manager before it plans or writes a brief: how an external library or API behaves, an end-to-end root-cause trace, or a broad sweep across repos, the knowledge vault, or tickets. It's read-only. Every finding is labeled either confirmed, with its source, or inferred. Findings are verified wherever possible; an unverified one is a last resort and must say what was tried and why it can't be checked. Leads send research back for up to three rounds per question, then you decide. It runs on sonnet by default, plain sweeps included, and in the full tier on opus for hard tracing. It isn't for scoping the files the lead is about to split; the lead reads those itself.
Worker (little-planet-factory:worker) implements one unit. It stays inside the files it was assigned, matches the surrounding code's conventions, runs targeted checks, and reports what it changed and what it assumed. It can't spawn other agents.
Signoff (little-planet-factory:signoff) is the last gate, and only the overseer invokes it. After inspection passes, it turns the source of truth into a checklist and checks each item against evidence in the code. The source can be a Cadence spec, a ticket or issue, a document, or your own request, including anything you added mid-session. It checks off verified spec criteria, flags loose ends (TODOs, skipped tests, stale docs, unresolved follow-ups, build output left behind without a reason), and runs a language pass: it verifies the copy inventory, flags existing copy the change made wrong, and flags terminology decisions. Those decisions come to you as interview questions. It doesn't rewrite prose itself. Git writes and the final report wait for SIGNED OFF. Under pull-request, it runs a second pass once the PR is open and its comments are triaged. It reads the PR itself and checks that every review comment was fixed or answered, and the work isn't reported done until that pass signs off too.
Inspector (little-planet-factory:inspector) reviews a change against its definition of done: brief compliance, correctness, security, efficiency, tooling, whether units from different agents fit together, and maintainability for broad changes. It's read-only. When the Codex plugin is set up, it also runs a Codex review alongside its own and keeps only the Codex findings it confirms. It returns a PASS / PASS WITH NOTES / FAIL verdict with each blocking finding tied to a file, and the lead sends that finding back to whoever owns the file. You can also call it directly for a review.
Every subagent except the researcher (sonnet) is pinned to opus, so the model you start the session on doesn't carry down to them. Agents without a pin, such as Claude Code's built-in agent types, get an explicit model of opus or sonnet on every call. Leads can downgrade per call: sonnet for a mechanical worker. No agent runs on haiku. Nothing runs above opus unless you ask for it, and then only for the work you named. The overseer runs on whatever model you start it with. The lite tier changes these defaults.
To spend less on subagents, start the session with LPF_TIER=lite:
LPF_TIER=lite claude --agent little-planet-factory:overseer
Only the environment variable sets the tier. Without it, or with any other value, the factory runs as described above.
In lite, managers stay on opus, and the overseer runs on whatever model you start it with, as usual. Workers, inspectors, researchers, signoff, and Claude Code's built-in agent types all run on sonnet. Leads never quietly bump a unit to opus. Foundational units and inspection triggers run on sonnet with their usual gates. Only when a unit hits the three-round limit does the overseer ask you whether to keep it on sonnet or run that one unit at full tier, and it moves to opus only if you say yes. Because the inspector is on sonnet, every lite inspection asks for the Codex second review, repair rounds included, when Codex is set up. Every other gate stays the same.
The SessionStart hook tells the overseer the session is lite, and a hook on agent spawns denies a worker, inspector, researcher, signoff, or built-in agent that isn't on sonnet, unless it's marked as a unit you approved for full tier. Manager spawns pass through and keep their opus pin. Forks are always denied in lite, since a fork runs on its caller's model whatever model the call asks for. The spawn hook only acts when the caller is a factory lead, the overseer session or a manager, so a plain Claude Code session or any other agent with LPF_TIER=lite set is left alone. Claude Code runs plugin hooks inside subagents too, so the spawn hook also covers the spawns managers make, and the managers' own instructions carry the same rule. Both hooks need node on your PATH. The tier comes from the SessionStart hook, so without node, or if CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 keeps LPF_TIER from reaching the hooks, the overseer isn't told the session is lite and the whole session runs at full tier.
The overseer and managers send work to the inspector when:
The overseer applies this to each unit and again to the combined change, since several small units can add up to a broad one. Small, low-risk edits skip inspection but still get verified.
The agents share ten skills beyond the platform guidance.
Version control (version-control) is preloaded into every agent. Before any git command that changes state, the agents work out the project's policy, then stay inside it:
| Policy | What the agents do |
|---|---|
none | Edit the working tree and leave everything uncommitted. This is the default when a project says nothing. |
commit | Commit verified changes on the current branch. Never push. |
push | Commit on the current branch and push it, e.g. straight to main. No branches or PRs. |
pull-request | Branch, commit, push the branch, and open a pull request. Never commit to the base branch. |
The policy comes from, in order: what you say in the session, then the project's CLAUDE.md or AGENTS.md, then the none default. Repo conventions like a PR template shape how the agents branch and write messages, but never grant a higher level. Conflicts resolve to the more restrictive reading. To state a policy unambiguously, add this to the project's CLAUDE.md:
## Version control
policy: pull-request
base: main
branch: <type>/<ticket>-<short-description>
commit-style: conventional
merge: never
Only policy is required. GitHub operations go through the gh CLI, one bare command per call, with no loops, polling scripts, or chained writes. Two exceptions: the Copilot review wait and the open-PR watch below, both bounded (or self-ending), read-only loops that run in the background. Under every policy, the agents stage explicit paths, never force-push, never skip hooks, never change git config, and never discard work they didn't create. Only the overseer runs git writes, once the work passes verification and inspection. Managers and workers never commit, because they share a working tree with agents still in flight.
Copilot review. When the agents open a PR on github.com, the overseer requests a GitHub Copilot review with gh (2.88 or newer), unless the repo already asked for one, and waits for it in the background for up to about 15 minutes. Then it triages every comment on the PR: review threads, review summaries, and conversation comments, from Copilot or anyone else, you included. Valid ones are fixed through the usual worker and inspection flow, pushed to the PR branch, and their threads resolved. The rest get a short reply saying why they don't apply and stay open for a person to weigh in. Replies post under your GitHub account. A PR gets at most two Copilot rounds, and signoff then checks the PR itself before the work is reported done. If Copilot isn't available, or gh is older than 2.88, the overseer notes it and still triages the comments. On another forge or GitHub Enterprise Server, or when no PR was opened, comments aren't checked, and a one-line note says so. While a PR stays open, the overseer also keeps one read-only watch on it in the background, snapshotting its reviews, comments, state, and checks, and it wakes the overseer the moment any of them changes — a new review, a comment, a CI result, or a merge — so nothing has to be pointed out. It restarts after every write the overseer makes on that PR, and stops once the PR merges or closes. A Copilot review that lands after the two-round cap still gets triaged, just not requested again.
React apps (react-apps) and Xcode projects (xcode-projects) load when the project uses that stack. They cover how to detect the tooling, how to verify with commands that exit (no dev servers, and no taking over your simulator), what to leave alone (lockfiles, signing, generated project files), which shared files need a single owner when work is split across agents, and what the inspector should weight in review. Agents build Xcode projects at low priority, with all agent builds together capped at half the cores, in a per-repository DerivedData folder outside Xcode's default and excluded from Time Machine, and run scoped tests with no host app on the Mac instead of a simulator. Tests hosted in your app, including UI tests, run on the Mac only if your CLAUDE.md has mac-hosted-tests: allowed under an ## Xcode heading. For a Mac-only app without it, the overseer asks you first. The overseer assigns per-session slots for concurrent builds and cleans them up. Units in one app (with its frameworks and local packages) or one TypeScript project build together, so they run one after another. Test runs pass -collect-test-diagnostics never, since a failing run otherwise hangs collecting a sysdiagnose. Temporary source edits, like mutation checks, run only as the tree's sole builder, in a trap-guarded script that backs up, edits, tests, and restores — comparing before it overwrites, and recovering if a kill or crash skipped the trap. The overseer runs a stall watchdog in the background, and any process it or an agent ends must be one it can prove it started, by a PID it recorded rather than a shared path.
Quality bar (quality-bar) is preloaded into every agent. It aims for no bugs on the first pass, so review confirms quality rather than discovering defects. Foundational work gets the full bar:
Everything else gets the normal inspection heuristic. Every repair diff is re-reviewed. After three review rounds on a unit, repairs stop. The overseer decides what has to change before work resumes: the brief, coordination between units, the agent, or the approach. It asks you when the decision is yours. Signoff's re-runs have the same limit. Claims and verification output are checked rather than trusted: a 0-test "pass" isn't a pass. Agents don't write product prose by default. Short labels are fine if they're listed in the copy inventory. A project's CLAUDE.md, or asking in the session, turns this off.
Asking questions (asking-questions) is preloaded into the overseer, manager, worker, and signoff. The researcher and inspector don't ask. When a decision is yours, it asks through the interview-question UI: each question has a sentence or two of self-contained context, one decision, and two to four options with their consequences, with a recommendation first. No questions are buried in prose, and none of its messages end with an inline "want me to…?". Managers and workers pass questions up in the same shape, and the overseer merges them into one interview.
Vercel (vercel) loads when a project deploys to Vercel. Deploys happen only by pushing to git, never with vercel deploy, --prod, redeploy, promote, or rollback, unless you ask for that specific action. Pushing still follows the version-control policy, so under none or commit the agent reports that the change is ready to deploy rather than deploying it. Unless you ask otherwise, every Vercel project gets Vercel Web Analytics and Speed Insights; the agent adds the components and tells you when Web Analytics still needs enabling in the dashboard. Neon is never used, whether directly, through the Vercel Marketplace, or as the former Vercel Postgres. When a project needs a database, the agent asks you which one. The skill also covers env vars (pull from Vercel, never hand-edit .env files, never handle secret values), function limits and costs, Next.js-on-Vercel rules, build file tracing, Blob access, and debugging from logs instead of redeploying.
Next.js (nextjs) loads when a project depends on next, on any host, alongside react-apps. Unless you ask otherwise, every site gets a dynamically generated Open Graph image through the opengraph-image file convention, and images go through next/image rather than a plain <img>. It covers the Metadata API and metadata file conventions, next/image, proxy.ts, hydration safety, Next 16 caching and "use server" rules, route-level CSS, and toolchain pins, labeled with the version they were verified on.
Web design (web-design) loads for any project that builds web pages or sites, whatever the framework. It covers SEO (titles, meta descriptions, canonical URLs, robots and sitemaps, structured data), favicons and app icons, tab titles (no em-dashes), social cards, theming meta, and accessibility and motion rules. The nextjs skill implements these in Next.
Linear (linear) needs the Linear MCP connected. It loads when the project's CLAUDE.md has a ## Linear block or you ask the overseer to work or refine tickets in Linear. The block names the project and a status mode: comment-only (the default), to-review, or to-done. Each session is pinned to that one project, so agents never list or work tickets from another project, even one in the same team. Unassigned Todo tickets are ranked by priority and, once you confirm them, run in batches of up to four that don't touch the same files or build together, with each other or with a paused, blocked, or halted ticket's unfinished work. Backlog tickets are refined with you and moved to Todo. Only the overseer writes to Linear. The overseer posts formatted Started, Progress, Paused, Blocked, and Done comments so teammates can follow along, plus a progress note when a ticket goes about an hour without one, when the session's scheduler is available.
Codex review (codex-review) is preloaded into the inspector, the only agent that uses it. It runs the optional Codex second review described under Optional integrations.
The agents use two MCP servers, Cadence and Telescope, and one Claude Code plugin, Codex, when they're available, and work normally without them. The linear skill is the exception: it needs the Linear MCP and stops if it isn't connected.
[Codex], and rejected ones get a one-line note. Codex never decides the verdict.Agents detect Cadence and Telescope by their tool names, so it doesn't matter what name you gave the server when you connected it.
hooks/register.ts 469 lines1// The factory status mod: in a factory overseer session it draws a band
2// above the prompt, puts a summary card above Agent rows, adds a summary to the
3// spinner, shows toasts, opens a /factory pane (usage, agents, ledger), and
4// tells the overseer once to update its ledger when auto-compaction is near.
5//
6// Read-only: it reads the ledgers in the session's scratchpad and never
7// writes a file. In any other session it shows nothing and injects nothing.
8// Every chain hook returns what `next` resolved, adding only the nudge's
9// context.
10
11import { update } from 'claude-code'
12import type { BoxProps, ElementConstructor, EngineInterface, Register, RenderElement, TextProps, Timer } from 'claude-code'
13
14import type { FactoryLedgers, FactoryUsage } from '../types'
15import { parseLedger } from './mod/ledger'
16import { ARMED, nextLimit, nextNudge, nudgeText } from './mod/nudge'
17import { endStatus, mergeRoster, requestTokens } from './mod/roster'
18import type { Row } from './mod/style'
19import { COLOR, rowsOf, seg } from './mod/style'
20import type { View } from './mod/view'
21import {
22 agentRow,
23 bandLayout,
24 COLLAPSE_COLUMNS,
25 finishToast,
26 limitToast,
27 MAX_ROW_COLUMNS,
28 paneRows,
29 spinnerSummary,
30} from './mod/view'
31
32const OVERSEER = 'little-planet-factory:overseer'
33const PANE = 'factory'
34const POLL_MS = 15_000
35const LEDGER = 'factory-ledger.md'
36const MANAGER_LEDGER = /^factory-ledger-([A-Za-z0-9._-]+)\.md$/
37
38// Either separator, so native Windows paths compare too.
39const trimSeparators = (path: string): string => path.replace(/[\\/]+$/, '')
40const parentOf = (path: string): string => trimSeparators(path.replace(/[\\/][^\\/]*$/, ''))
41
42const SESSION = { plugin: 'little-planet-factory', key: 'session' } as const
43const AGENTS = { plugin: 'little-planet-factory', key: 'agents' } as const
44const DETAILS = { plugin: 'little-planet-factory', key: 'details' } as const
45const USAGE = { plugin: 'little-planet-factory', key: 'usage' } as const
46const LEDGERS = { plugin: 'little-planet-factory', key: 'ledgers' } as const
47const NUDGE = { plugin: 'little-planet-factory', key: 'nudge' } as const
48const STEP_TOKENS = { plugin: 'little-planet-factory', key: 'stepTokens' } as const
49const LIMITS = { plugin: 'little-planet-factory', key: 'limits' } as const
50const LIMIT_KINDS = ['five_hour', 'seven_day']
51
52const NO_USAGE: FactoryUsage = { rateLimits: [] }
53const NO_LEDGERS: FactoryLedgers = { main: null, managers: [] }
54
55type Engine = EngineInterface
56
57type Kit = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
58
59// One row of styled segments as a Box holding one truncating Text, with an
60// optional element (the band's Button) at its right.
61function rowElement({ Box, Text }: Kit, row: Row, key: string, end?: RenderElement): RenderElement {
62 const text = Text({
63 wrap: 'truncate-end',
64 children: row.map(part =>
65 part.color === undefined && part.dim === undefined && part.bold === undefined
66 ? part.text
67 : Text({
68 ...(part.color === undefined ? {} : { color: part.color }),
69 ...(part.dim === undefined ? {} : { dimColor: true }),
70 ...(part.bold === undefined ? {} : { bold: true }),
71 children: part.text,
72 }),
73 ),
74 })
75 return end === undefined ? Box({ key, children: text }) : Box({ key, justifyContent: 'space-between', children: [text, end] })
76}
77
78const isSame = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b)
79
80const isActive = async ($: Engine): Promise<boolean> => (await $.state.get(SESSION)).value?.active === true
81
82// Only the overseer's ledger and exact manager-ledger names from a
83// non-recursive listing of the scratchpad are read, and only when the name
84// resolves to a regular file directly inside the scratchpad, both resolved
85// the same way. A symlink to a file in the scratchpad is read, as
86// factory-ledger.mjs allows; one that leads anywhere else is not.
87async function readLedgers($: Engine, scratchpadDir: string | null): Promise<FactoryLedgers> {
88 if (scratchpadDir === null) return NO_LEDGERS
89 const dir = trimSeparators(scratchpadDir)
90 const resolved = (await $.fs.stat(dir, { resolve: true }).catch(() => undefined))?.realPath
91 if (resolved === undefined) return NO_LEDGERS
92 const root = trimSeparators(resolved)
93 const read = async (name: string) => {
94 const target = (await $.fs.stat(`${dir}/${name}`, { resolve: true }).catch(() => undefined))
95 const realPath = target?.kind === 'file' ? target.realPath : undefined
96 if (realPath === undefined || parentOf(realPath) !== root) return null
97 return $.fs.read(realPath).then(parseLedger, () => null)
98 }
99 const [main, entries] = await Promise.all([read(LEDGER), $.fs.list(dir).catch(() => [])])
100 const names = entries
101 .filter(entry => entry.kind !== 'dir' && MANAGER_LEDGER.test(entry.name))
102 .map(entry => entry.name)
103 .sort()
104 const managers = await Promise.all(
105 names.map(async name => {
106 const ledger = await read(name)
107 const slug = MANAGER_LEDGER.exec(name)![1]!
108 return ledger === null ? [] : [ledger.next === undefined ? { slug } : { slug, next: ledger.next }]
109 }),
110 )
111 return { main, managers: managers.flat() }
112}
113
114async function view($: Engine): Promise<View> {
115 const [agents, details, usage, ledgers, now] = await Promise.all([
116 $.state.get(AGENTS),
117 $.state.get(DETAILS),
118 $.state.get(USAGE),
119 $.state.get(LEDGERS),
120 $.clock.now(),
121 ])
122 return {
123 roster: mergeRoster(agents.value ?? null, details.value ?? {}),
124 usage: usage.value ?? NO_USAGE,
125 ledgers: ledgers.value ?? NO_LEDGERS,
126 now,
127 }
128}
129
130
131async function refresh($: Engine): Promise<void> {
132 const session = (await $.state.get(SESSION)).value
133 if (session?.active !== true) return
134 const [agents, ledgers] = await Promise.all([
135 $.agent.list().then(
136 list =>
137 list.map(({ id, description, type, status, parentId }) =>
138 parentId === undefined ? { id, description, type, status } : { id, description, type, status, parentId },
139 ),
140 () => null,
141 ),
142 readLedgers($, session.scratchpadDir),
143 ])
144 // An unchanged value isn't written, so its readers aren't drawn again.
145 const [currentAgents, currentLedgers] = await Promise.all([$.state.get(AGENTS), $.state.get(LEDGERS)])
146 await Promise.all([
147 isSame(currentAgents.value, agents) ? undefined : $.state.set(AGENTS, agents),
148 isSame(currentLedgers.value, ledgers) ? undefined : $.state.set(LEDGERS, ledgers),
149 ])
150}
151
152// The poll: unchanged values aren't written, so while an agent runs the
153// drawings are asked to redraw anyway, to move its elapsed time on.
154async function tick($: Engine): Promise<void> {
155 await refresh($)
156 await foldStepTokens($)
157 // Elapsed times and reset countdowns move with the clock, not with state.
158 const { roster, usage } = await view($)
159 if (roster.some(entry => entry.status === 'running') || usage.rateLimits.some(limit => limit.resetsAt !== undefined)) {
160 $.ui.invalidate('ui.render')
161 }
162}
163
164// A step's token figure goes to a key no drawing reads, so a subagent's
165// requests don't redraw anything; the poll and turn.complete fold it in.
166async function recordStep($: Engine, agentId: string, tokens: number): Promise<void> {
167 if (await isActive($)) await update($, STEP_TOKENS, steps => ({ ...steps, [agentId]: tokens }))
168}
169
170// Moves the steps' figures into the agents' details, for agents the mod knows
171// (the engine's own forks are dropped), writing only what changed.
172async function foldStepTokens($: Engine): Promise<void> {
173 const steps = (await $.state.get(STEP_TOKENS)).value ?? {}
174 const ids = Object.keys(steps)
175 if (ids.length === 0) return
176 const listed = new Set(((await $.state.get(AGENTS)).value ?? []).map(agent => agent.id))
177 const details = (await $.state.get(DETAILS)).value ?? {}
178 const changed = ids.filter(id => (details[id] !== undefined || listed.has(id)) && details[id]?.tokens !== steps[id])
179 if (changed.length > 0) {
180 await update($, DETAILS, current => ({
181 ...current,
182 ...Object.fromEntries(changed.map(id => [id, { ...current?.[id], tokens: steps[id]! }])),
183 }))
184 }
185 // A step recorded since the read stays for the next fold.
186 await update($, STEP_TOKENS, current => Object.fromEntries(Object.entries(current ?? {}).filter(([id, tokens]) => steps[id] !== tokens)))
187}
188
189// Records an agent's token figure, for an agent the mod knows: one it saw
190// spawn or the agent list names (the engine's own forks are neither). Used
191// for a completed Agent result's own total, which is rare.
192async function setAgentTokens($: Engine, agentId: string, tokens: number): Promise<void> {
193 const isListed = (await $.state.get(AGENTS)).value?.some(agent => agent.id === agentId) === true
194 await update($, DETAILS, details => {
195 const detail = details?.[agentId]
196 return detail === undefined && !isListed ? (details ?? {}) : { ...details, [agentId]: { ...detail, tokens } }
197 })
198}
199
200// A completed foreground Agent call carries Claude Code's own `totalTokens`;
201// it wins over what the mod worked out from the steps.
202async function recordEngineTokens($: Engine, record: unknown): Promise<void> {
203 const { status, agentId, totalTokens } = (record ?? {}) as { status?: unknown; agentId?: unknown; totalTokens?: unknown }
204 if (status === 'completed' && typeof agentId === 'string' && typeof totalTokens === 'number') {
205 await setAgentTokens($, agentId, totalTokens)
206 }
207}
208
209// Takes a pending nudge for delivery; undefined when none is pending.
210async function claimNudge($: Engine): Promise<number | undefined> {
211 if ((await $.state.get(NUDGE)).value?.phase !== 'pending') return undefined
212 let claimed: number | undefined
213 await update($, NUDGE, nudge => {
214 claimed = nudge?.phase === 'pending' ? nudge.percent : undefined
215 return claimed === undefined ? (nudge ?? ARMED) : { phase: 'fired' as const, percent: claimed }
216 })
217 return claimed
218}
219
220// Puts back a nudge whose delivery didn't happen, unless something has
221// changed it since it was claimed.
222async function unclaimNudge($: Engine, percent: number): Promise<void> {
223 await update($, NUDGE, nudge =>
224 nudge?.phase === 'fired' && nudge.percent === percent ? { phase: 'pending' as const, percent } : (nudge ?? ARMED),
225 )
226}
227
228async function openPane($: Engine) {
229 return $.ui.open({ id: PANE, title: 'Factory', closeOnEscape: true })
230}
231
232// A reload cancels the old module's timers and loads this at undefined, so
233// one poll runs per load.
234let poll: Timer | undefined
235
236async function activate($: Engine): Promise<void> {
237 // Clears a status line an older version of the mod left.
238 $.ui.status(undefined)
239 poll ??= $.clock.every(POLL_MS, () => void tick($).catch(() => undefined))
240 await $.command.register({ name: 'factory', description: 'Show factory status: usage, agents, and the ledger' })
241 await refresh($)
242}
243
244export const register: Register = on => {
245 // Fires at load and at every reload, not after /clear. With the state kept
246 // from before a reload, the session picks up where it was.
247 on('session.start', async ($, e, next) => {
248 const result = await next(e)
249 if (await isActive($)) await activate($)
250 return result
251 })
252
253 on('classic.SessionStart', async ($, e, next) => {
254 const result = await next(e)
255 if (e.agent_id !== undefined) return result
256 if (e.agent_type !== OVERSEER) {
257 poll?.cancel()
258 poll = undefined
259 await $.state.set(SESSION, { active: false, scratchpadDir: null })
260 $.ui.status(undefined)
261 return result
262 }
263 // Every classic event but PreToolUse carries scratchpad_dir; the types
264 // don't declare it.
265 const scratchpadDir = (e as { scratchpad_dir?: unknown }).scratchpad_dir
266 if (e.source === 'clear') {
267 await Promise.all([
268 $.state.set(AGENTS, null),
269 $.state.set(DETAILS, {}),
270 $.state.set(USAGE, NO_USAGE),
271 $.state.set(LEDGERS, NO_LEDGERS),
272 $.state.set(NUDGE, ARMED),
273 $.state.set(STEP_TOKENS, {}),
274 ])
275 // Rate limits are the account's, so their toasts aren't re-armed here.
276 }
277 if (e.source === 'compact') await $.state.set(NUDGE, ARMED)
278 await $.state.set(SESSION, {
279 active: true,
280 scratchpadDir: typeof scratchpadDir === 'string' && scratchpadDir !== '' ? scratchpadDir : null,
281 })
282 await activate($)
283 return result
284 })
285
286 on('session.measure', async ($, e, next) => {
287 const result = await next(e)
288 if (!(await isActive($))) return result
289 // The threshold comes from the local estimate, which sends no request.
290 const autoCompactThreshold = e.changed.includes('context')
291 ? (await $.session.usage({ breakdown: 'summary' }).catch(() => undefined))?.context.breakdown?.autoCompactThreshold
292 : (await $.state.get(USAGE)).value?.autoCompactThreshold
293 const usage: FactoryUsage = {
294 context: e.context,
295 rateLimits: e.rateLimits,
296 ...(e.cost === undefined ? {} : { costUsd: e.cost.usd }),
297 ...(autoCompactThreshold === undefined ? {} : { autoCompactThreshold }),
298 }
299 await $.state.set(USAGE, usage)
300 await update($, NUDGE, nudge => nextNudge(nudge ?? ARMED, usage))
301 const limits = { ...(await $.state.get(LIMITS)).value }
302 const now = await $.clock.now()
303 for (const reading of e.rateLimits.filter(one => LIMIT_KINDS.includes(one.kind))) {
304 const step = nextLimit(limits[reading.kind], reading)
305 limits[reading.kind] = step.limit
306 if (step.isFired) $.ui.toast(limitToast(reading.kind, reading.percentUsed, reading.resetsAt, now))
307 }
308 await $.state.set(LIMITS, limits)
309 return result
310 })
311
312 on('agent.spawn', async ($, e, next) => {
313 const result = await next(e)
314 const { agentId, model } = result
315 if (agentId === undefined || model === undefined || !(await isActive($))) return result
316 const startedAt = await $.clock.now()
317 const detail = {
318 description: e.description,
319 type: e.subagentType,
320 model,
321 startedAt,
322 status: 'running' as const,
323 toolUseId: e.tool_use_id,
324 }
325 await update($, DETAILS, details => ({
326 ...details,
327 [agentId]: e.parentAgentId === undefined ? detail : { ...detail, parentId: e.parentAgentId },
328 }))
329 await refresh($)
330 return result
331 })
332
333 on('turn.complete', async ($, e, next) => {
334 const result = await next(e)
335 if (!(await isActive($))) return result
336 const { agentId, usage } = e
337 if (agentId !== undefined) {
338 const endedAt = await $.clock.now()
339 await foldStepTokens($)
340 // Only agents the mod knows: the engine's own forks (compaction, memory)
341 // run turns under ids no list names.
342 const isListed = (await $.state.get(AGENTS)).value?.some(agent => agent.id === agentId) === true
343 await update($, DETAILS, details => {
344 const detail = details?.[agentId]
345 if (detail === undefined && !isListed) return details ?? {}
346 // Tokens come from the run's last request (turn.step), not this sum.
347 const model = detail?.model ?? usage?.model
348 return { ...details, [agentId]: { ...detail, ...(model === undefined ? {} : { model }), endedAt, status: endStatus(e.reason) } }
349 })
350 await refresh($)
351 // Only a known agent is in the roster. The list may not have caught up,
352 // so the toast tells the run's own outcome.
353 const entry = (await view($)).roster.find(one => one.id === agentId)
354 if (entry !== undefined) {
355 $.ui.toast(finishToast({ ...entry, status: endStatus(e.reason), endedAt }, endedAt))
356 }
357 return result
358 }
359 await refresh($)
360 return result
361 })
362
363 on('tool.call', async ($, e, next) => {
364 if (!(await isActive($))) return next(e)
365 const { agentId } = e
366 if (agentId !== undefined) {
367 // A finished background agent that calls a tool has woken up again.
368 const status = (await $.state.get(DETAILS)).value?.[agentId]?.status
369 if (status === 'done' || status === 'failed') {
370 await update($, DETAILS, details => {
371 const { endedAt: _, ...detail } = details?.[agentId] ?? {}
372 return { ...details, [agentId]: { ...detail, status: 'running' as const } }
373 })
374 await refresh($)
375 }
376 }
377 // Claimed only once the call has answered: a call that rejects or is
378 // denied leaves the nudge pending for the next one.
379 const result = await next(e)
380 if (e.tool === 'Agent') await recordEngineTokens($, result.deny === undefined && result.isError !== true ? result.result : undefined)
381 if (agentId !== undefined || result.deny !== undefined) return result
382 const percent = await claimNudge($)
383 return percent === undefined ? result : { ...result, context: [...(result.context ?? []), nudgeText(percent)] }
384 })
385
386 // Each subagent request's usage gives Claude Code's own token figure for the
387 // agent: its last request's total. The main loop's steps (no agentId) don't
388 // match, so they never pass through this module. The figure is recorded
389 // without waiting, so the step's result reaches the subagent at once. (The
390 // step's usage doesn't say whether a request was unmetered, so none is
391 // skipped.)
392 on('turn.step', { agentId: /./ }, async function* ($, e, next) {
393 const result = yield* next(e)
394 if (e.agentId !== undefined && result.usage !== null) {
395 void recordStep($, e.agentId, requestTokens(result.usage)).catch(() => undefined)
396 }
397 return result
398 })
399
400 // The fallback when the overseer makes no tool call before the next prompt.
401 // The claim comes before `next` here, since context rides in on the way
402 // down; a prompt that doesn't enter gives the nudge back.
403 on('prompt.submit', async ($, e, next) => {
404 const percent = (await isActive($)) ? await claimNudge($) : undefined
405 if (percent === undefined) return next(e)
406 try {
407 const result = await next({ ...e, context: [...(e.context ?? []), nudgeText(percent)] })
408 if (result.drop !== undefined) await unclaimNudge($, percent)
409 return result
410 } catch (error) {
411 await unclaimNudge($, percent)
412 throw error
413 }
414 })
415
416 on('command.run', { command: 'factory' }, async $ => {
417 const opened = await openPane($)
418 return opened.isPlaced ? {} : { text: `Factory pane not shown: ${opened.reason}` }
419 })
420
421 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
422 const kit = $.ui.resolve(e)
423 const columns = Math.min(MAX_ROW_COLUMNS, Math.max(1, Math.floor(e.props.bodyColumns)))
424 const rows = (await isActive($))
425 ? paneRows(await view($), columns)
426 : [[seg('Not a factory overseer session.', { dim: true })]]
427 return kit.Box({ flexDirection: 'column', children: rows.map((row, index) => rowElement(kit, row, `line-${index}`)) })
428 })
429
430 // The band above the prompt is shared: other mods' drawing (`next`) stays,
431 // under ours, and ours takes only the rows it leaves. A tree taller than
432 // `maxRows` scrolls, and Buttons scrolled out of view lose their hotkeys.
433 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
434 const below = await next(e)
435 if (e.props.hasSurvey || !(await isActive($))) return below
436 const kit = $.ui.resolve(e)
437 const columns = Math.min(MAX_ROW_COLUMNS, Math.max(1, Math.floor(e.props.bodyColumns)))
438 const { isFramed, rows, hasButton } = bandLayout(await view($), columns, e.props.maxRows - rowsOf(below, columns))
439 if (rows.length === 0) return below
440 const button = hasButton
441 ? kit.Button({ key: 'open-factory', hotkey: '1', plain: true, label: '/factory', onPress: () => void openPane($) })
442 : undefined
443 const lines = rows.map((row, index) => rowElement(kit, row, `band-${index}`, index === rows.length - 1 ? button : undefined))
444 const band = isFramed
445 ? kit.Box({ flexDirection: 'column', borderStyle: 'round', borderColor: COLOR.accent, paddingX: 1, children: lines })
446 : kit.Box({ flexDirection: 'column', children: lines })
447 return kit.Box({ flexDirection: 'column', children: [kit.Box({ paddingRight: COLLAPSE_COLUMNS, children: band }), below] })
448 })
449
450 // A one-line card heads each known Agent row, and the engine's own row
451 // (live progress, ctrl+o detail, other plugins' drawing) stays beneath it.
452 // With nothing known of the agent, the row is the engine's alone. No other
453 // tool's row reaches this hook.
454 on('ui.render', { component: 'ToolUse', props: { tool: 'Agent' } }, async ($, e, next) => {
455 if (!(await isActive($))) return next(e)
456 const current = await view($)
457 const entry = current.roster.find(one => one.toolUseId === e.props.tool_use_id)
458 if (entry === undefined) return next(e)
459 const kit = $.ui.resolve(e)
460 return kit.Box({ flexDirection: 'column', children: [rowElement(kit, agentRow(entry, current.now, false), 'agent'), await next(e)] })
461 })
462
463 // The engine's spinner stays; the summary rides after its ellipsis.
464 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
465 const summary = (await isActive($)) ? spinnerSummary(await view($)) : undefined
466 return next(summary === undefined ? e : { ...e, props: { ...e.props, suffix: `${e.props.suffix} · ${summary}` } })
467 })
468}
469hooks/mod/ledger.ts 57 lines1// Reads the overseer's factory ledger (agents/overseer.md, "Ledger"). A model
2// writes it, so the parse is loose: sections are found by heading text in any
3// order, bullets and bold are stripped, and anything unrecognised is ignored.
4
5import type { FactoryLedger } from '../../types'
6
7export const UNIT_STATES = ['queued', 'dispatching', 'mid-edit', 'stopped unfinished', 'done'] as const
8
9// Scan order when a line has no explicit `state:` token: the most specific
10// first and "done" last, so "stopped unfinished" never counts as done.
11const SCAN = ['stopped unfinished', 'mid-edit', 'dispatching', 'queued', 'done'] as const
12const EXPLICIT = /\bstate\s*[:=]\s*(queued|dispatching|mid-edit|stopped unfinished|done)\b/i
13const NONE = /^\(?(none|n\/a|nothing)\b/i
14
15const clean = (line: string): string =>
16 line
17 .replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '')
18 .replace(/[*`]/g, '')
19 .trim()
20
21export function unitState(line: string): string | undefined {
22 const explicit = EXPLICIT.exec(line)
23 if (explicit) return explicit[1]!.toLowerCase()
24 const text = line.toLowerCase()
25 return SCAN.find(state => new RegExp(`(^|[^a-z-])${state}([^a-z-]|$)`).test(text))
26}
27
28export function parseLedger(text: string): FactoryLedger {
29 const ledger: FactoryLedger = { questions: [], units: {}, background: [] }
30 let section = ''
31
32 for (const raw of text.split(/\r?\n/)) {
33 const heading = /^\s*##\s+(.*)$/.exec(raw)
34 if (heading) {
35 section = clean(heading[1]!).toLowerCase()
36 continue
37 }
38 const line = clean(raw)
39 if (line === '' || line.startsWith('#')) continue
40
41 if (section === '') {
42 const status = /^status\s*:\s*(\S+)/i.exec(line)
43 if (status) ledger.status = status[1]!.toLowerCase()
44 } else if (section.startsWith('next step')) {
45 ledger.next ??= line
46 } else if (section.startsWith('open questions')) {
47 if (!NONE.test(line)) ledger.questions.push(line)
48 } else if (section.startsWith('background work')) {
49 if (!NONE.test(line)) ledger.background.push(line)
50 } else if (section.startsWith('units')) {
51 const state = unitState(line)
52 if (state) ledger.units[state] = (ledger.units[state] ?? 0) + 1
53 }
54 }
55 return ledger
56}
57hooks/mod/nudge.ts 46 lines1// When the overseer's context nears auto-compaction, the mod tells it once to
2// bring its ledger up to date. Levels are percent of the context window.
3
4import type { FactoryLimit, FactoryNudge, FactoryUsage } from '../../types'
5
6export const ARMED: FactoryNudge = { phase: 'armed', percent: 0 }
7const FALLBACK_TRIGGER = 75
8const HYSTERESIS = 10
9
10// The fill and the trigger: 90% of the auto-compaction threshold when the
11// engine reports one, else 75%. Undefined when the fill isn't known.
12export function contextLevel(usage: FactoryUsage): { percent: number; trigger: number } | undefined {
13 const context = usage.context
14 if (context === undefined) return undefined
15 const { tokens, window, percent } = context
16 if (usage.autoCompactThreshold !== undefined && tokens !== undefined && window > 0) {
17 return { percent: (tokens / window) * 100, trigger: (90 * usage.autoCompactThreshold) / window }
18 }
19 return percent === undefined ? undefined : { percent, trigger: FALLBACK_TRIGGER }
20}
21
22export function nextNudge(nudge: FactoryNudge, usage: FactoryUsage): FactoryNudge {
23 const level = contextLevel(usage)
24 if (level === undefined) return nudge
25 if (level.percent < level.trigger - HYSTERESIS) return ARMED
26 if (level.percent < level.trigger || nudge.phase === 'fired') return nudge
27 return { phase: 'pending', percent: level.percent }
28}
29
30export const nudgeText = (percent: number): string =>
31 `Factory mod: context is at ${Math.round(percent)}% and auto-compaction is near. ` +
32 'Update the factory ledger now (Units, Background work, Next step).'
33
34// A rate-limit window's toast fires once at 80% and re-arms below 70% or when
35// the window resets (its reset time moves).
36export function nextLimit(
37 limit: FactoryLimit | undefined,
38 reading: { percentUsed: number; resetsAt?: string },
39): { limit: FactoryLimit; isFired: boolean } {
40 const hasReset = limit?.resetsAt !== undefined && reading.resetsAt !== undefined && reading.resetsAt !== limit.resetsAt
41 const isArmed = (limit?.isArmed ?? true) || hasReset || reading.percentUsed < 70
42 const isFired = isArmed && reading.percentUsed >= 80
43 const resetsAt = reading.resetsAt === undefined ? {} : { resetsAt: reading.resetsAt }
44 return { limit: { isArmed: isArmed && !isFired, ...resetsAt }, isFired }
45}
46hooks/mod/roster.ts 69 lines1// The agent roster: $.agent.list() gives each agent's status and parent; the
2// details the mod collects from events add model, times and tokens.
3
4import type { FactoryAgent, FactoryAgentDetail } from '../../types'
5
6export type RosterEntry = Omit<FactoryAgentDetail, 'status'> & { id: string; status: 'running' | 'done' | 'failed' | 'other' }
7
8const LISTED: Record<string, RosterEntry['status']> = {
9 running: 'running',
10 completed: 'done',
11 failed: 'failed',
12 killed: 'failed',
13}
14
15// What a finished run's reason means for the agent.
16export const endStatus = (reason: string): 'done' | 'failed' => (reason === 'answer' ? 'done' : 'failed')
17
18// Listed agents take the list's status; an agent the mod saw that the list
19// no longer names (it may prune finished ones) keeps its own.
20export function mergeRoster(
21 agents: readonly FactoryAgent[] | null,
22 details: Readonly<Record<string, FactoryAgentDetail>>,
23): RosterEntry[] {
24 const listed = (agents ?? []).map(agent => ({
25 ...details[agent.id],
26 id: agent.id,
27 description: agent.description,
28 type: agent.type,
29 parentId: agent.parentId,
30 status: LISTED[agent.status] ?? details[agent.id]?.status ?? 'other',
31 }))
32 const ids = new Set(listed.map(entry => entry.id))
33 const unlisted = Object.entries(details)
34 .filter(([id]) => !ids.has(id))
35 .map(([id, detail]) => ({ ...detail, id, status: detail.status ?? ('other' as const) }))
36 return [...listed, ...unlisted]
37}
38
39// Depth-first order under the overseer. An agent whose parent isn't in the
40// roster sits at the top level.
41export function rosterTree(entries: readonly RosterEntry[]): { entry: RosterEntry; depth: number }[] {
42 const ids = new Set(entries.map(entry => entry.id))
43 const children = new Map<string | undefined, RosterEntry[]>()
44 for (const entry of entries) {
45 const parent = entry.parentId !== undefined && ids.has(entry.parentId) ? entry.parentId : undefined
46 children.set(parent, [...(children.get(parent) ?? []), entry])
47 }
48 const out: { entry: RosterEntry; depth: number }[] = []
49 const walk = (parent: string | undefined, depth: number): void => {
50 for (const entry of children.get(parent) ?? []) {
51 out.push({ entry, depth })
52 walk(entry.id, depth + 1)
53 }
54 }
55 walk(undefined, 1)
56 return out
57}
58
59// Claude Code's own figure for an agent's tokens (the Agent result's
60// `totalTokens`): one request's input, cache writes, cache reads and output,
61// taken from the agent's last request. Summing these over every request would
62// count the cached context again on each one.
63export const requestTokens = (usage: {
64 input_tokens: number
65 output_tokens: number
66 cache_read_input_tokens: number | null
67 cache_creation_input_tokens: number | null
68}): number => usage.input_tokens + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.output_tokens
69hooks/mod/style.ts 168 lines1// Styled text the band, the pane and the agent rows share: a row is a list
2// of segments, each drawn as a Text in a theme color, so light and dark
3// themes both read.
4
5export type Seg = { text: string; color?: string; dim?: true; bold?: true }
6export type Row = Seg[]
7
8// Claude Code's own theme keys.
9export const COLOR = { accent: 'claude', head: 'suggestion', good: 'success', warn: 'warning', bad: 'error' } as const
10
11export const seg = (text: string, style: Omit<Seg, 'text'> = {}): Seg => ({ text, ...style })
12
13// Display cells: wide East Asian characters and emoji take two. Common
14// ranges only; anything else counts one.
15const WIDE: [number, number][] = [
16 [0x1100, 0x115f],
17 // Emoji-presentation symbols the terminal draws wide: ⌚⌛, ⏩–⏳, ☔☕,
18 // ✅, ❌, ❓–❕, ❗, ➕–➗, ⭐; the ambiguous rest of U+2600–27BF stays at one.
19 [0x231a, 0x231b],
20 [0x23e9, 0x23ec],
21 [0x23f0, 0x23f0],
22 [0x23f3, 0x23f3],
23 [0x2614, 0x2615],
24 [0x2705, 0x2705],
25 [0x270a, 0x270b],
26 [0x2728, 0x2728],
27 [0x274c, 0x274c],
28 [0x2753, 0x2755],
29 [0x2757, 0x2757],
30 [0x2795, 0x2797],
31 [0x2b50, 0x2b50],
32 [0x2e80, 0x303e],
33 [0x3041, 0x33ff],
34 [0x3400, 0x4dbf],
35 [0x4e00, 0x9fff],
36 [0xa000, 0xa4cf],
37 [0xac00, 0xd7a3],
38 [0xf900, 0xfaff],
39 [0xfe30, 0xfe4f],
40 [0xff00, 0xff60],
41 [0xffe0, 0xffe6],
42 [0x1f000, 0x1f2ff],
43 [0x1f300, 0x1f64f],
44 [0x1f680, 0x1f6ff],
45 [0x1f900, 0x1f9ff],
46 [0x1fa70, 0x1faff],
47 [0x20000, 0x3fffd],
48]
49const cells = (char: string): number => {
50 const code = char.codePointAt(0)!
51 return WIDE.some(([low, high]) => code >= low && code <= high) ? 2 : 1
52}
53export const textWidth = (text: string): number => [...text].reduce((sum, char) => sum + cells(char), 0)
54
55// Control characters would make the surface refuse the whole tree.
56const sanitize = (text: string): string => text.replace(/[\u0000-\u001f\u007f]+/g, ' ')
57
58// The leading characters of `chars` that fit in `width` cells.
59const lead = (chars: readonly string[], width: number): string => {
60 let out = ''
61 let used = 0
62 for (const char of chars) {
63 if (used + cells(char) > width) break
64 out += char
65 used += cells(char)
66 }
67 return out
68}
69
70// Cuts text to `width` cells, ending in an ellipsis when it was cut.
71export function truncate(text: string, width: number): string {
72 const clean = sanitize(text)
73 if (textWidth(clean) <= width) return clean
74 return width <= 0 ? '' : `${lead([...clean], width - 1)}…`
75}
76
77// Cuts text to `width` cells with the ellipsis in the middle, keeping both
78// ends, so names that share a prefix still differ.
79export function truncateMiddle(text: string, width: number): string {
80 const clean = sanitize(text)
81 if (textWidth(clean) <= width || width <= 2) return truncate(clean, width)
82 const head = lead([...clean], Math.ceil((width - 1) / 2))
83 const tail = [...lead([...clean].reverse(), width - 1 - textWidth(head))].reverse().join('')
84 return `${head}…${tail}`
85}
86
87export const rowText = (row: Row): string => row.map(part => part.text).join('')
88export const rowWidth = (row: Row): number => textWidth(rowText(row))
89
90// Cuts a row to `width` cells: the segment that crosses it ends in an ellipsis.
91export function fitRow(row: Row, width: number): Row {
92 const out: Row = []
93 let used = 0
94 for (const part of row) {
95 const text = truncate(part.text, width - used)
96 if (text === '') break
97 out.push({ ...part, text })
98 used += textWidth(text)
99 if (text !== sanitize(part.text)) break
100 }
101 return out
102}
103
104// Joins rows with a separator, skipping empty ones.
105export const joinRows = (rows: Row[], separator: Seg): Row =>
106 rows.filter(row => row.length > 0).flatMap((row, index) => (index === 0 ? row : [separator, ...row]))
107
108// Green under 60%, yellow under 80%, red from 80%.
109export const levelColor = (percent: number): string => (percent < 60 ? COLOR.good : percent < 80 ? COLOR.warn : COLOR.bad)
110
111export function gauge(label: string, percent: number, bar: number): Row {
112 const color = levelColor(percent)
113 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * bar)
114 return [
115 seg(`${label} `),
116 ...(bar > 0 ? [seg('█'.repeat(filled), { color }), seg('░'.repeat(bar - filled), { dim: true })] : []),
117 seg(`${bar > 0 ? ' ' : ''}${Math.round(percent)}%`, { color }),
118 ].filter(part => part.text !== '')
119}
120
121// How many rows a drawn tree takes in `columns` cells, estimated from its
122// shape: what another mod drew in the band. It errs high where it must guess,
123// since too low lets the band overflow. Core's own empty drawing
124// (`type: 'engine'`) takes none.
125type Drawn = { type?: string; props?: Record<string, unknown>; children?: unknown[] }
126
127// Every string inside a Text, nested Texts included, as it reads.
128const textOf = (node: unknown): string =>
129 typeof node === 'string' || typeof node === 'number'
130 ? String(node)
131 : Array.isArray(node)
132 ? node.map(textOf).join('')
133 : typeof node === 'object' && node !== null && (node as Drawn).type === 'Text'
134 ? textOf((node as Drawn).children ?? [])
135 : ''
136
137// Rows of text: one per line, and a line wider than `columns` wraps unless
138// its Text truncates.
139const textRows = (text: string, columns: number, isTruncated: boolean): number =>
140 text
141 .split('\n')
142 .reduce((sum, line) => sum + (isTruncated ? 1 : Math.max(1, Math.ceil(textWidth(line) / Math.max(1, columns)))), 0)
143
144export function rowsOf(node: unknown, columns: number): number {
145 if (node === null || node === undefined || typeof node === 'boolean') return 0
146 if (typeof node !== 'object') return textRows(String(node), columns, false)
147 if (Array.isArray(node)) return node.reduce((sum: number, child) => sum + rowsOf(child, columns), 0)
148 const { type, props = {}, children = [] } = node as Drawn
149 if (type === 'engine') return 0
150 if (type === 'Text') return textRows(textOf(children), columns, String(props.wrap ?? '').startsWith('truncate'))
151 if (type === 'Markdown' || type === 'Code') return textRows(String(props.text ?? props.source ?? ''), columns, false)
152 if (type !== 'Box') return 1
153 if (props.display === 'none') return 0
154 const num = (key: string) => (typeof props[key] === 'number' ? (props[key] as number) : 0)
155 const border = props.borderStyle === undefined ? 0 : 2
156 const outer = typeof props.width === 'number' ? Math.min(columns, props.width) : columns
157 const inner = Math.max(1, outer - 2 * (num('paddingX') + num('padding')) - num('paddingLeft') - num('paddingRight') - border)
158 const shown = children.filter(child => child !== null && child !== undefined && typeof child !== 'boolean')
159 const kids = shown.map(child => rowsOf(child, inner))
160 const isRow = props.flexDirection === undefined || String(props.flexDirection).startsWith('row')
161 const gap = isRow ? 0 : (num('rowGap') || num('gap')) * Math.max(0, shown.length - 1)
162 const content = isRow ? Math.max(0, ...kids) : kids.reduce((sum, rows) => sum + rows, 0) + gap
163 const padding = num('paddingTop') + num('paddingBottom') + 2 * (num('paddingY') + num('padding'))
164 const margin = num('marginTop') + num('marginBottom') + 2 * (num('marginY') + num('margin'))
165 if (typeof props.height === 'number') return props.height + margin
166 return Math.max(num('minHeight'), content + padding + border) + margin
167}
168hooks/mod/view.ts 378 lines1// What the mod shows: the band above the prompt, the spinner's summary,
2// agent rows, toasts and the /factory pane. Every segment
3// whose figure is unknown is left out.
4
5import type { FactoryLedgers, FactoryUsage } from '../../types'
6import { UNIT_STATES } from './ledger'
7import type { RosterEntry } from './roster'
8import { rosterTree } from './roster'
9import type { Row, Seg } from './style'
10import { COLOR, fitRow, gauge, joinRows, levelColor, rowWidth, seg, textWidth, truncate, truncateMiddle } from './style'
11
12export type View = { roster: RosterEntry[]; usage: FactoryUsage; ledgers: FactoryLedgers; now: number }
13
14const PREFIX = 'little-planet-factory:'
15const TITLE = 'Little Planet Factory'
16export const MARKS: Record<RosterEntry['status'], string> = { running: '●', done: '✓', failed: '✗', other: '·' }
17const STATUS_COLORS: Record<RosterEntry['status'], string | undefined> = {
18 running: COLOR.accent,
19 done: COLOR.good,
20 failed: COLOR.bad,
21 other: undefined,
22}
23const WINDOWS: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
24// The band draws its own collapse control at its right edge.
25export const COLLAPSE_COLUMNS = 5
26// Room for the band's `1: /factory` Button and a gap before it.
27export const BUTTON_COLUMNS = 12
28// Wider than any terminal; keeps each Text well under the 10,000-character limit.
29export const MAX_ROW_COLUMNS = 1000
30
31const percent = (n: number): string => `${Math.round(n)}%`
32
33export function tokens(n: number): string {
34 if (n < 1000) return String(Math.round(n))
35 return n < 1_000_000 ? `${(n / 1000).toFixed(1)}k` : `${(n / 1_000_000).toFixed(1)}M`
36}
37
38export function duration(ms: number): string {
39 const s = Math.max(0, Math.round(ms / 1000))
40 if (s < 60) return `${s}s`
41 if (s < 3600) return `${Math.floor(s / 60)}m`
42 if (s < 86_400) return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
43 return `${Math.floor(s / 86_400)}d${Math.floor((s % 86_400) / 3600)}h`
44}
45
46export const contextPercent = ({ context }: FactoryUsage): number | undefined =>
47 context?.percent ??
48 (context?.tokens !== undefined && context.window > 0 ? (context.tokens / context.window) * 100 : undefined)
49
50const resetIn = (resetsAt: string | undefined, now: number): string | undefined => {
51 const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
52 return Number.isNaN(at) ? undefined : duration(at - now)
53}
54
55const counts = (roster: readonly RosterEntry[]): Record<RosterEntry['status'], number> => {
56 const out = { running: 0, done: 0, failed: 0, other: 0 }
57 for (const entry of roster) out[entry.status] += 1
58 return out
59}
60
61const shortType = (entry: RosterEntry): string => (entry.type ?? 'agent').replace(PREFIX, '')
62
63const elapsedOf = (entry: RosterEntry, now: number): number | undefined =>
64 entry.startedAt === undefined
65 ? undefined
66 : entry.status === 'running'
67 ? now - entry.startedAt
68 : entry.endedAt === undefined
69 ? undefined
70 : entry.endedAt - entry.startedAt
71
72// One agent: status dot, type, description, model, elapsed time and tokens.
73// The card above an Agent row leaves tokens out: the engine's row under it
74// shows its own count, which is figured differently.
75export function agentRow(entry: RosterEntry, now: number, withTokens = true): Row {
76 const elapsed = elapsedOf(entry, now)
77 const rest = [
78 entry.description,
79 entry.model?.replace(/^claude-/, ''),
80 elapsed === undefined ? undefined : duration(elapsed),
81 entry.tokens === undefined || !withTokens ? undefined : `${tokens(entry.tokens)} tok`,
82 ].filter((part): part is string => part !== undefined && part !== '')
83 const color = STATUS_COLORS[entry.status]
84 // Descriptions come from the model: bounded, control characters replaced.
85 return fitRow(
86 [
87 seg(`${MARKS[entry.status]} `, color === undefined ? { dim: true } : { color }),
88 seg(shortType(entry), { bold: true }),
89 ...rest.map((part, index) => seg(` · ${part}`, index === 0 ? {} : { dim: true })),
90 ],
91 MAX_ROW_COLUMNS,
92 )
93}
94
95export function finishToast(entry: RosterEntry, now: number): string {
96 const elapsed = elapsedOf(entry, now)
97 return [
98 `${MARKS[entry.status]} ${shortType(entry)} · ${entry.description ?? entry.id} ${entry.status === 'done' ? 'done' : 'failed'}`,
99 ...(elapsed === undefined ? [] : [duration(elapsed)]),
100 ...(entry.tokens === undefined ? [] : [`${tokens(entry.tokens)} tok`]),
101 ].join(' · ')
102}
103
104export function limitToast(kind: string, percentUsed: number, resetsAt: string | undefined, now: number): string {
105 const resets = resetIn(resetsAt, now)
106 return `${WINDOWS[kind] ?? kind} limit at ${percent(percentUsed)}${resets === undefined ? '' : ` · resets in ${resets}`}`
107}
108
109export function spinnerSummary({ roster, ledgers }: View): string | undefined {
110 const { running } = counts(roster)
111 if (running === 0) return undefined
112 const next = ledgers.main?.next
113 return `${running} agent${running === 1 ? '' : 's'} running${next === undefined ? '' : ` · next: ${truncate(next, 40)}`}`
114}
115
116// The band: a framed card when there is room, else fewer rows, down to one.
117// The last row carries the Button, so it leaves BUTTON_COLUMNS free.
118
119function gaugesRow({ usage, now }: View, width: number): Row {
120 const ctx = contextPercent(usage)
121 const limit = (kind: string) => usage.rateLimits.find(one => one.kind === kind)
122 const fiveHour = limit('five_hour')
123 const sevenDay = limit('seven_day')
124 const build = (bar: number, withReset: boolean, withSevenDay: boolean, withFiveHour = true): Row => {
125 const reset = withReset ? resetIn(fiveHour?.resetsAt, now) : undefined
126 return joinRows(
127 [
128 ctx === undefined ? [] : gauge('ctx', ctx, bar),
129 fiveHour === undefined || !withFiveHour
130 ? []
131 : [...gauge('5h', fiveHour.percentUsed, bar), ...(reset === undefined ? [] : [seg(` ↻${reset}`, { dim: true })])],
132 sevenDay === undefined || !withSevenDay ? [] : gauge('7d', sevenDay.percentUsed, bar),
133 ],
134 seg(' '),
135 )
136 }
137 // Drop the 7d gauge first, then the reset time, then shorten the bars, then
138 // the 5h gauge. A figure is never cut: with no room even for ctx, no row.
139 const tries = [
140 build(8, true, true),
141 build(8, true, false),
142 build(8, false, false),
143 build(4, false, false),
144 build(0, false, false),
145 build(0, false, false, false),
146 ]
147 return tries.find(row => rowWidth(row) <= width) ?? []
148}
149
150// The cells each description gets out of `room`, shared fairly: none gets
151// more than its own width, and what a short one leaves goes to the rest.
152export function shareWidths(widths: readonly number[], room: number): number[] {
153 const out = widths.map(() => 0)
154 let left = room
155 let open = widths.map((_, index) => index).filter(index => widths[index]! > 0)
156 while (open.length > 0 && left >= open.length) {
157 const each = Math.floor(left / open.length)
158 for (const index of open) {
159 const give = Math.min(each, widths[index]! - out[index]!)
160 out[index]! += give
161 left -= give
162 }
163 open = open.filter(index => out[index]! < widths[index]!)
164 }
165 return out
166}
167
168// A description is shown only if it gets at least this many cells (or all of
169// a shorter one); otherwise fewer chips are shown and the rest fold into +N.
170// Fewer readable chips beat many cut to a few letters.
171const MIN_DESCRIPTION = 12
172
173// One chip per type, description and status, running first; identical agents
174// share a chip with ×N, and what doesn't fit folds into +N.
175function chipsRow({ roster }: View, width: number): Row {
176 const order: RosterEntry['status'][] = ['running', 'failed', 'done', 'other']
177 const groups: { entry: RosterEntry; count: number }[] = []
178 for (const entry of order.flatMap(status => roster.filter(one => one.status === status))) {
179 const same = groups.find(
180 group =>
181 group.entry.status === entry.status && shortType(group.entry) === shortType(entry) && group.entry.description === entry.description,
182 )
183 if (same === undefined) groups.push({ entry, count: 1 })
184 else same.count += 1
185 }
186 const total = roster.length
187 for (let shown = groups.length; shown >= 1; shown -= 1) {
188 const visible = groups.slice(0, shown)
189 const hidden = total - visible.reduce((sum, group) => sum + group.count, 0)
190 const more = hidden === 0 ? 0 : textWidth(` +${hidden}`)
191 const fixed = visible.reduce(
192 (sum, { entry, count }, index) =>
193 sum +
194 (index === 0 ? 0 : 2) +
195 textWidth(`${MARKS[entry.status]} ${shortType(entry)}`) +
196 (entry.description ? 1 : 0) +
197 (count > 1 ? textWidth(` ×${count}`) : 0),
198 0,
199 )
200 const widths = visible.map(({ entry }) => textWidth(entry.description ?? ''))
201 const shares = shareWidths(widths, width - fixed - more)
202 const isEnough = fixed + more <= width && widths.every((full, index) => shares[index]! >= Math.min(full, MIN_DESCRIPTION))
203 if (!isEnough) continue
204 return [
205 ...visible.flatMap(({ entry, count }, index) => [
206 ...(index === 0 ? [] : [seg(' ')]),
207 seg(`${MARKS[entry.status]} `, { color: STATUS_COLORS[entry.status] ?? COLOR.head }),
208 seg(`${shortType(entry)}${entry.description ? `·${truncateMiddle(entry.description, shares[index]!)}` : ''}`),
209 ...(count > 1 ? [seg(` ×${count}`, { dim: true })] : []),
210 ]),
211 ...(hidden === 0 ? [] : [seg(` +${hidden}`, { dim: true })]),
212 ]
213 }
214 return total === 0 || textWidth(`+${total}`) > width ? [] : [seg(`+${total}`, { dim: true })]
215}
216
217function nextRow({ ledgers }: View): Row {
218 if (ledgers.main === null) return [seg('No factory ledger yet.', { dim: true })]
219 const next = ledgers.main.next
220 return next === undefined ? [] : [seg('next ', { dim: true }), seg('▸ ', { color: COLOR.accent }), seg(next)]
221}
222
223function headerRow({ ledgers }: View, width: number): Row {
224 const title: Row = [seg('◉ ', { color: COLOR.accent }), seg(TITLE, { color: COLOR.accent, bold: true })]
225 const status = ledgers.main?.status
226 const right: Row =
227 status === undefined ? [] : [seg(status, status === 'active' ? { color: COLOR.good } : { dim: true })]
228 const gap = width - rowWidth(title) - rowWidth(right)
229 return gap < 1 ? fitRow(title, width) : [...title, seg(' '.repeat(gap)), ...right]
230}
231
232function footerRow({ ledgers }: View): Row {
233 if (ledgers.main === null) return []
234 const open = ledgers.main.questions.length
235 return open === 0
236 ? [seg('no open questions', { dim: true })]
237 : [seg(`${open} open question${open === 1 ? '' : 's'}`, { color: COLOR.warn })]
238}
239
240// One line for a short band. Figures (ctx, 5h, the running count) go in that
241// order and are dropped whole, with everything after them, never cut; only
242// prose (the next step, or the title when nothing is known) may be truncated.
243function oneLine(view: View, width: number): Row {
244 const ctx = contextPercent(view.usage)
245 const fiveHour = view.usage.rateLimits.find(limit => limit.kind === 'five_hour')
246 const { running } = counts(view.roster)
247 const next = view.ledgers.main?.next
248 const figures: Row[] = [
249 ctx === undefined ? [] : [seg('ctx '), seg(percent(ctx), { color: levelColor(ctx) })],
250 fiveHour === undefined ? [] : [seg('5h '), seg(percent(fiveHour.percentUsed), { color: levelColor(fiveHour.percentUsed) })],
251 running === 0 ? [] : [seg(`${running} running`, { color: COLOR.accent })],
252 ].filter(figure => figure.length > 0)
253 const glyph: Row = [seg('◉', { color: COLOR.accent })]
254 const separator = (row: Row): Seg => (row === glyph ? seg(' ') : seg(' · ', { dim: true }))
255 let row = glyph
256 for (const figure of figures) {
257 const longer = [...row, separator(row), ...figure]
258 if (rowWidth(longer) > width) return row
259 row = longer
260 }
261 // With nothing to show, the title or the empty-ledger line stands in.
262 const prose: Row =
263 figures.length > 0
264 ? next === undefined
265 ? []
266 : [seg('▸ ', { color: COLOR.accent }), seg(next)]
267 : view.ledgers.main === null
268 ? nextRow(view)
269 : [seg(TITLE, { color: COLOR.accent, bold: true })]
270 const lead = separator(row)
271 const room = width - rowWidth(row) - rowWidth([lead])
272 return prose.length === 0 || room < 4 ? row : [...row, lead, ...fitRow(prose, room)]
273}
274
275// The band for `columns` cells and `maxRows` rows. No rows means there is no
276// room to draw. The last row carries the Button where it fits.
277export function bandLayout(
278 view: View,
279 columns: number,
280 maxRows: number,
281): { isFramed: boolean; rows: Row[]; hasButton: boolean } {
282 const width = columns - COLLAPSE_COLUMNS
283 if (maxRows < 1 || width < 1) return { isFramed: false, rows: [], hasButton: false }
284 if (maxRows >= 7 && width >= 48) {
285 const inner = width - 4 // border and padding
286 const middle = [gaugesRow(view, inner), chipsRow(view, inner), fitRow(nextRow(view), inner)].filter(row => row.length > 0)
287 return {
288 isFramed: true,
289 rows: [headerRow(view, inner), ...middle, fitRow(footerRow(view), inner - BUTTON_COLUMNS)],
290 hasButton: true,
291 }
292 }
293 // Each row is built for its own width, so the last is built narrower for
294 // the Button rather than cut afterwards.
295 const builders: ((room: number) => Row)[] =
296 width >= 30 && maxRows >= 3
297 ? [room => gaugesRow(view, room), room => chipsRow(view, room), room => fitRow(nextRow(view), room)]
298 : width >= 30 && maxRows >= 2
299 ? [room => gaugesRow(view, room), room => fitRow(nextRow(view), room)]
300 : []
301 const kept = builders.filter(build => build(width).length > 0)
302 if (kept.length > 0) {
303 return {
304 isFramed: false,
305 rows: kept.map((build, index) => build(index === kept.length - 1 ? width - BUTTON_COLUMNS : width)),
306 hasButton: true,
307 }
308 }
309 // One line: the Button stays only if something beyond the glyph fits beside
310 // it; otherwise the line takes the whole width.
311 const beside = width >= BUTTON_COLUMNS ? oneLine(view, width - BUTTON_COLUMNS) : []
312 return beside.length > 1
313 ? { isFramed: false, rows: [beside], hasButton: true }
314 : { isFramed: false, rows: [oneLine(view, width)], hasButton: false }
315}
316
317// The /factory pane.
318
319function usageRows(usage: FactoryUsage, now: number): Row[] {
320 const rows: Row[] = []
321 const ctx = contextPercent(usage)
322 const context = usage.context
323 if (ctx !== undefined && context !== undefined) {
324 const parts: string[] = []
325 if (context.tokens !== undefined) {
326 parts.push(`${tokens(context.tokens)} / ${tokens(context.window)} tokens`)
327 if (usage.autoCompactThreshold !== undefined) {
328 parts.push(`${tokens(Math.max(0, usage.autoCompactThreshold - context.tokens))} left before auto-compaction`)
329 }
330 }
331 rows.push([...gauge('context', ctx, 10), ...parts.map(part => seg(` · ${part}`, { dim: true }))])
332 }
333 for (const limit of usage.rateLimits) {
334 const resets = resetIn(limit.resetsAt, now)
335 rows.push([
336 ...gauge(WINDOWS[limit.kind] ?? limit.kind, limit.percentUsed, 10),
337 ...(resets === undefined ? [] : [seg(` · resets in ${resets}`, { dim: true })]),
338 ])
339 }
340 if (usage.costUsd !== undefined) rows.push([seg(`cost $${usage.costUsd.toFixed(2)}`)])
341 return rows
342}
343
344function ledgerRows({ main, managers }: FactoryLedgers): Row[] {
345 if (main === null) return [[seg('No factory ledger yet.', { dim: true })]]
346 const units = UNIT_STATES.filter(state => main.units[state]).map(state => `${main.units[state]} ${state}`)
347 const open = main.questions.length
348 return [
349 ...(main.status === undefined
350 ? []
351 : [[seg('status '), seg(main.status, main.status === 'active' ? { color: COLOR.good } : { dim: true })]]),
352 ...(main.next === undefined ? [] : [[seg('next: '), seg(main.next, { color: COLOR.accent })]]),
353 [seg('open questions: '), open === 0 ? seg('none', { dim: true }) : seg(String(open), { color: COLOR.warn })],
354 ...main.questions.map(question => [seg(` ${question}`)]),
355 [seg(`units: ${units.length === 0 ? 'none' : units.join(' · ')}`)],
356 ...(main.background.length === 0 ? [] : [[seg('background:')]]),
357 ...main.background.map(line => [seg(` ${line}`, { dim: true })]),
358 ...managers.map(manager => [seg(`manager ${manager.slug}: `), seg(manager.next ?? 'no next step', { dim: true })]),
359 ]
360}
361
362export function paneRows(view: View, columns: number): Row[] {
363 const usage = usageRows(view.usage, view.now)
364 const heading = (text: string): Row => [seg(text, { color: COLOR.head, bold: true })]
365 const indent = (row: Row, depth: number): Row => [seg(' '.repeat(depth)), ...row]
366 const rows: Row[] = [
367 heading('Usage'),
368 ...(usage.length === 0 ? [[seg(' no usage reported yet', { dim: true })]] : usage.map(row => indent(row, 1))),
369 heading('Agents'),
370 [seg(' ◉ ', { color: COLOR.accent }), seg('overseer', { bold: true })],
371 ...rosterTree(view.roster).map(({ entry, depth }) => indent(agentRow(entry, view.now), depth + 1)),
372 heading('Ledger'),
373 ...ledgerRows(view.ledgers).map(row => indent(row, 1)),
374 ]
375 return rows.map(row => fitRow(row, columns))
376}
377
378types/index.d.ts 64 lines1// The factory status mod's session state ($.state), one value per key.
2
3export type FactorySession = { active: boolean; scratchpadDir: string | null }
4
5// One subagent as $.agent.list() last reported it.
6export type FactoryAgent = { id: string; description: string; type: string; status: string; parentId?: string }
7
8// What the mod learned about a subagent from its events, keyed by agent id.
9export type FactoryAgentDetail = {
10 description?: string
11 type?: string
12 parentId?: string
13 model?: string
14 startedAt?: number
15 endedAt?: number
16 tokens?: number
17 status?: 'running' | 'done' | 'failed'
18 // The Agent tool call that spawned it, for its transcript row.
19 toolUseId?: string
20}
21
22export type FactoryUsage = {
23 context?: { tokens?: number; window: number; percent?: number }
24 rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
25 costUsd?: number
26 autoCompactThreshold?: number
27}
28
29export type FactoryLedger = {
30 status?: string
31 next?: string
32 questions: string[]
33 units: Record<string, number>
34 background: string[]
35}
36
37export type FactoryLedgers = {
38 main: FactoryLedger | null
39 managers: { slug: string; next?: string }[]
40}
41
42// One rate-limit window's toast: armed until it fires at 80%; re-armed below
43// 70% or when the window resets.
44export type FactoryLimit = { isArmed: boolean; resetsAt?: string }
45
46// armed: waiting for the threshold; pending: crossed, not yet delivered; fired: delivered.
47export type FactoryNudge = { phase: 'armed' | 'pending' | 'fired'; percent: number }
48
49declare module 'claude-code' {
50 interface PluginState {
51 'little-planet-factory': {
52 session: FactorySession
53 agents: FactoryAgent[] | null
54 details: Record<string, FactoryAgentDetail>
55 usage: FactoryUsage
56 ledgers: FactoryLedgers
57 nudge: FactoryNudge
58 limits: Record<string, FactoryLimit>
59 // Each subagent's last request's tokens, until folded into details.
60 stepTokens: Record<string, number>
61 }
62 }
63}
64