See and cap your subagents: cards with cost, tokens, context and progress, spawn caps with an override you approve, tree and team views, idle alerts and a Stop…

An add-on plugin of the loopkeeper set (its command stays /agent-map). An agent deck for the session's subagents. It caps how many agents a session may spawn, warns before their results eat the main context, and shows each agent as a card with its cost, tokens, context fill, model, effort and task progress, a tree row, or a member of its team's lane. It also shows who spawned whom, what each one is doing right now, how long it has run, the messages between agents, and which ones wait on you. Each running agent has a Stop button. When a run fans out, you see at a glance which agents are working, which are stuck or waiting on you and what each costs, and a runaway spawn loop stops at a cap you set.
Use it when
/compact or hand off.Install: /plugin install lk-agent-map@loopkeeper, after /plugin marketplace add connectwithub/loopkeeper (more under Install). Requires Claude Code 2.1.294 or later.
Data handling: it keeps the band setting, the caps you save with /agent-map cap and the chosen view in its own plugin store, and the agents' state in session state; the cap rows set in /config live in Claude Code's own settings. It reads ~/.claude/agent-map.json and <project>/.claude/agent-map.json and writes no file. It runs no process, makes no model calls and sends nothing over the network; the only tool it calls is TaskStop, for one agent, after you press that agent's Stop twice. It collects no personal data; the saved settings stay until you delete them. Details under Footprint.
Commands: /agent-map and its options are listed under Commands.
The pane opens on the Agents cards, drawn with the kit (hooks/kit.ts, a verbatim copy of the loopkeeper core's kit v3): round, unfilled cards whose colors are theme keys, so they read in light and dark themes. In the Desktop app each card has a pixel critter avatar and a progress bar drawn as Svg; in the terminal the avatar is the role glyph and the bar is text. An example (the figures are illustrative):
┌──────────────────────┐ ┌──────────────────────┐ ┌────────────────────┐
│ Cost │ │ Tokens │ │ Time │
│ ≈$0.10 │ │ 60k │ │ 1:10 │
└──────────────────────┘ └──────────────────────┘ └────────────────────┘
[ Collapse ] [ Cards ] [ Tree ] [ Team ]
◉ main (orchestrator) · 100k tok · 14 tools
AGENTS · 2 1 RUNNING
╭──────────────────────────────────────────────────────────────────────╮
│ ◆ ▸ build the signup form ● Stop │
│ careful Opus 5.5 · high │
│ 4/5 · Signup ctx 20% · 40k ≈$0.10 0:40 │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╌╌╌╌╌╌╌╌╌╌╌╌ 80% │
╰──────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────╮
│ ◈ ▸ survey the repo ✓ │
│ medium Haiku 4.5 · medium │
│ 0 tools ctx 10% · 20k ≈$0.05 0:30 │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ 75% │
╰──────────────────────────────────────────────────────────────────────╯
PLANNED · 1
◎ review the plan reviewer · Opus · asked 0:05 ago
[ Clear finished ] [ Log ] [ Close ]
The tree view (/agent-map tree), with illustrative figures too:
main (orchestrator) · 1.0M tok · 14 tools
├─ ● survey-projects general-purpose sonnet 00:57 12 tools 80k tok ⏸ waiting: Bash Stop
│ ⋯ Bash: rm -rf build
│ ├─ ✓ explore Explore haiku 00:20 4 tools 10k tok
│ └─ ● ⑂ side look general-purpose opus 06:10 2 tools 10k tok ⚠ idle 5m Stop
├─ ● ◇ reviewer teammate sonnet 01:12 5 tools 20k tok Stop
│ ⋯ Read: /src/index.ts
├─ ■ research-docs claude-code-guide opus 02:50 12 tools 90k tok by you (agent map)
└─ ✗ build-x general-purpose opus 01:10 3 tools 40k tok
● running · ✓ done · ✗ failed (API error, refusal, or a failed hook) · ○ aborted (interrupted) · ■ stopped (with who stopped it)
⑂ a fork of its parent · ◇ a teammate · ⏸ a tool call waiting on your permission answer · ⚠ idle past the alert threshold · ⊘ muted
| Command | What it does | | :- | :- | | /agent-map | Open the pane and focus it (Esc closes it) | | /agent-map clear | Remove finished agents (done, failed, aborted). Running agents stay. | | /agent-map band on / off | Show or hide the band. The setting is saved in $.store and kept between sessions. | | /agent-map text | Print the chosen view (tree, cards or team) as plain text in the transcript; the tree when none was chosen. Use it on the mobile app or anywhere panes don't draw. | | /agent-map cards / tree / team | Switch the pane's view and open it. Saved between sessions. /agent-map view cards\|tree\|team does the same. | | /agent-map log | Print the last 40 lifecycle events | | /agent-map cap | Show the caps and where each stands | | /agent-map cap concurrent <n> / turn <n> / session <n> | Set a spawn cap; 0 turns it off | | /agent-map cap returned <k> | Set the returned-tokens budget in thousands (200 or 200k) | | /agent-map cap warn <0.1-1> / grace <ms> | Set warnAt, or the waiting grace (0 marks at once) | | /agent-map cap reset [kind] | Clear what /agent-map cap saved for that kind (concurrent, turn, session, returned, warn, grace), or for every kind; the value from the files, the settings or the defaults comes back |
Agent map): the cards above, or the tree or team view. The first spawn of the session opens it without taking focus, and a toast points to /agent-map when the terminal is too narrow for an unasked pane. Each agent's title (a card's first line, a tree row) is a button. Press it to show its full description, the first 200 characters of its prompt, tool counts by name, its last activity, tokens by kind (input, output, cache read, cache write), status, timing, request count, parent and flags (fork, teammate, background, muted, stopped by). Header: the session tiles and the Collapse/Expand, Cards, Tree and Team buttons (the shown view's is marked as the primary button). Under the body, the Caps block: each cap with its value (no cap for 0) and where it came from (see Configuration). Footer buttons: Clear finished, Log and Close. The header, the body, the caps, the log and the footer sit one row apart, and so do the cards; the rows of a tree stay tight. Running agents count up once a second, and the timer stops when nothing runs.TaskStop for that agent, with your press as the consent, and the row turns to ■ stopped with by you (agent map). A TaskStop the model makes is recorded the same way, with by main or the calling agent.TaskStop refuses (a foreground subagent, or an id it doesn't know), you get a toast and the agent is muted instead: each further tool call it makes is refused with a reason that asks it to stop calling tools and return its result now. The row shows ⊘ muted.⋯ Bash: npm test line with its last tool and the first 40 characters of that call's input. For an agent no tool call was seen from (one that started before a reload, or one only the engine's list names), the line comes from the tail of its own transcript ($.session.messages({ agentId })).ask for an agent's tool call and the ask is still open after waitGraceMs (4 s by default), that agent's row shows ⏸ waiting: Bash in the 'claude' color (its card ◷) until the call runs or is refused. An ask that auto mode's classifier approves within the grace never shows, so the mark doesn't flicker. When a permission dialog actually opens (classic.PermissionRequest), the mark shows at once, and from then on in the session the dialog is the only signal. A pending mark is dropped when the call runs or the agent's turn ends. For the main loop it shows on the header line.stuckAfterMs (5 minutes by default) gets ⚠ idle 5m in red on its row and one toast per stall. Any activity clears it. An agent waiting on you isn't counted as idle.⑂ marks a fork (agent.spawn's fork), ◇ a teammate. The model column shows what the agent actually runs on: the model of its latest request (turn.step), else the spawn's./agent-map, and when a loop no spawn announced makes a request, the map merges $.agent.list(): new ids (forks, teammates, workflow agents, agents from before a reload) appear under their parentId, and agents the list reports finished end. A parent that would close a loop is left at main.agents ● 3/4 running ✓ 5 done ✗ 1 ● ok with 400k tok · results 100k → main (example figures) on the right in a quiet color, and ○ n aborted / ■ n stopped in front of the tokens when there are any. The /4 is the concurrency cap; results … → main appears once subagent results passed 1k tokens. A count is colored only when it is not zero (running in the suggestion color, done in success, failed in error). The last chip is the cap state: ● ok (success), ● near cap ('claude': a cap at warnAt of its limit, or the returned-tokens warning) or ● at cap (error: a cap reached, or a spawn denied this turn). No chip is filled. The band stays hidden until the first subagent of the session spawns; other mods' band content is kept below it.→ msg to <label> line under the sender, for SendMessage traffic between main and a subagent or between subagents.Tokens are input + output + cache read + cache write, summed from each model request (turn.step). The band total counts subagents only.
▸, which opens the card's details, the title in bold (the spawn's description, with ⑂ or ◇ in front), and at the far right the status (● running and ✓ done in success, ◷ waiting on you in 'claude', ✗ failed in error, ■ stopped and ○ aborted quiet) and Stop while it runs.careful Opus 5.5 · high. low → light (success), medium → medium (suggestion), high → careful and xhigh → thorough ('claude'), max → max (error); no effort, no word. The effort is its latest request's (turn.step's e.effort), else the spawn's when the event carries one.TodoWrite calls, 4/5 · Signup (completed over all items, and the in-progress item's activeForm, else its content), or 12 tools without a list; on the right ctx 5% · 40k ≈$0.10 0:40 (example figures): context fill, tokens, cost and elapsed time.by you (agent map) follow on a line of their own.▸ adds tools 12 · last: Bash npm test, → 40k to main once its result came back, and the agent's full record (description, prompt excerpt, tools, tokens, status, model). The avatar: in the Desktop app a 12×12 pixel critter (about 44 px) with a hat in the effort's color, a pennant while it runs, X eyes when it failed and faded once finished; the main loop wears a violet crown. Its alt text names the role and state (hard running). In the terminal, the role glyph in the status color. The role comes from a [role:x] tag in the prompt or description, then the type and description words (recon, explore or search → recon; verif or valid → verify; review, critic or audit → reviewer), then an Opus model → hard, else execute.
Context % is an approximation: the agent's latest request's input + cache read + cache write tokens over the session's context window, read once from $.session.usage().context.window at session start (200k when it can't be read). A subagent's own window isn't exposed, so an agent on a model with a different window reads off by that ratio.
Cost is an estimate from list prices: each request's input × the input price + output × output + cache read × cache read + cache write × the 1-hour cache-write price, per million tokens, using the model that answered it. A model the table doesn't know adds nothing and shows — while it has tokens and no known cost. Prices as of 2026-10 (partly confirmed), USD per million tokens, matched by the first family the model id contains, longest names first:
| Family | Input | Output | Cache read | Cache write (1 h) | | :- | -: | -: | -: | -: | | fable-5-1 | 10 | 50 | 1.00 | 20 | | opus-5-5 | 4 | 20 | 0.20 | 8 | | opus-5 | 5 | 25 | 0.50 | 10 | | opus-4 | 5 | 25 | 0.50 | 10 | | sonnet-5-5 | 2 | 10 | 0.20 | 4 | | sonnet-5 | 2 | 10 | 0.20 | 4 | | sonnet | 3 | 15 | 0.30 | 6 | | haiku-5-5 | 1 | 5 | 0.10 | 2 | | haiku | 1 | 5 | 0.10 | 2 |
Override or add rows with a prices object in ~/.claude/agent-map.json or <cwd>/.claude/agent-map.json (the files the caps come from; the project file wins), by family, as [input, output, cacheRead, cacheWrite] or an object with those four keys:
{ "prices": { "opus-5-5": [4, 20, 0.2, 8], "my-gateway-model": { "input": 1, "output": 5, "cacheRead": 0.1, "cacheWrite": 2 } } }
Every agent.spawn passes a gate first. It counts running agents (plus spawns already admitted and not yet started), spawns in this main-loop turn and spawns in the session:
| Cap | Default | Counts | | :- | :- | :- | | maxConcurrent | 4 | running subagents, forks and teammates included | | maxPerTurn | 8 | spawns since the last main-loop turn.start; forks and teammates don't count | | maxPerSession | 40 | every spawn this session |
Change them in /config (the lk-agent-map rows), in the files, or with /agent-map cap; Configuration has the order.
warnAt (0.75), the band's state chip turns to ● near cap and one toast goes out per turn, such as agents 3/4 running, near the concurrency cap.agent-map: concurrency cap reached (4/4). Wait for running subagents to return, or batch the work into fewer agents. The user can raise it with /agent-map cap concurrent <n>. The band turns red for the rest of the turn.[cap-override: <reason>] asks you instead: agent-map: <reason>. Allow one more agent beyond the cap? with Allow this one, Allow the rest of the turn and Deny. When no one can be asked (-p, a dismissed dialog), it's refused. The marker is removed from the description and prompt the agent gets.The text each Agent tool call returns to its caller is measured (characters / 4) and stored on the agent as returnedTokens: rows and cards show → 40k to main. Results that reach the main loop add up to a session total; past warnAt × maxReturnedTokens (112.5k of 150k by default) the band shows results 120k → main on its quiet right side, the cap chip turns ● near cap, and one toast suggests /compact or a handoff. A background agent's result arrives later as a task notification, which is counted the same way when it names the agent. The subagent's own tokens (80k tok) are shown as before.
/agent-map cards, the pane's default): the Agents cards above. As text (/agent-map text once cards were chosen), each card is four lines in a rounded border: label, type, model and status glyph; spawned 10:31 → running 00:57 → ⏸ waiting (Bash) → done 10:38; 12 tools · last Bash: npm test · 80k tok · → 40k to main; the parent and [Stop]./agent-map tree): the map above./agent-map team): lanes by parent. team of main: 3 running · 2 done first, then each agent's children in a lane indented under it, and teammates (◇) in a teammates lane of their own.$.store. The header's Cards, Tree and Team buttons switch, and Log shows the last 12 lifecycle events at the bottom. Until you choose, the pane draws the cards and /agent-map text prints the tree./agent-map log prints the last 40.| Option | Default | What it does | | :- | :- | :- | | stuckAfterMs | 300000 | How long a running agent may go without a tool call or model request before it gets ⚠ idle and a toast. 0 turns the alert off. | | maxConcurrent | 4 | Max subagents running at once (see Spawn caps). A whole number; 0 means no cap. | | maxPerTurn | 8 | Max subagents per main-loop turn. A whole number; 0 means no cap. | | maxPerSession | 40 | Max subagents per session. A whole number; 0 means no cap. | | warnAtPercent | 75 | Warn at this share of a cap, from 1 to 100 (warnAt = the value / 100). | | maxReturnedTokens | 150000 | Max tokens subagent results add to the main context per session (see Context cost of results); it warns, it never refuses a spawn. A whole number; 0 means no cap. |
Set them in /config (the lk-agent-map rows) or the Desktop app's plugin settings; they are kept under pluginConfigs in settings. The caps follow a change at once: /config changes go through a config.set hook that refuses a value out of range (a negative or fractional count, a percent outside 1-100), and a settings file changed by other means loads them again (classic.ConfigChange). stuckAfterMs takes effect when the plugin loads. A value the settings file holds out of range is ignored, and the default stands.
The caps are merged in this order, a later layer winning: the defaults, then the settings (a value equal to the default counts as the default), then ~/.claude/agent-map.json, then <cwd>/.claude/agent-map.json, then what /agent-map cap <kind> <n> saved in $.store (kept between sessions until /agent-map cap reset [kind] clears it). The pane's Caps block shows each value with its source: default, settings, user file, project file or saved (/agent-map cap). The files also take waitGraceMs, and the prices object described under The Agents cards:
{ "maxConcurrent": 4, "maxPerTurn": 8, "maxPerSession": 40, "warnAt": 0.75, "maxReturnedTokens": 150000, "waitGraceMs": 4000 }
Needs Claude Code 2.1.294 or later. From the loopkeeper marketplace, inside a Claude Code session:
/plugin marketplace add connectwithub/loopkeeper
/plugin install lk-agent-map@loopkeeper
Turn it off or on again with /plugin disable lk-agent-map@loopkeeper and /plugin enable lk-agent-map@loopkeeper. To try it from a clone without installing it: claude --plugin-dir /path/to/loopkeeper/plugins/lk-agent-map. Check and test it from its folder with claude plugin validate --strict . and claude plugin test ..
What claude plugin validate --strict . reports for this plugin (Claude Code 2.1.294), and what that means:
| Area | What the plugin has |
|---|---|
| Hooks module | One: ./register.ts, in hooks/hooks.json. |
| Hook events | session.start, classic.SessionStart (clear, resume, fork), config.set (this plugin's cap rows), classic.ConfigChange, tool.call, tool.check, agent.spawn, classic.PermissionRequest, classic.PreToolUse, turn.start, turn.step, turn.complete, session.send, session.receive, command.run (agent-map), ui.render (the band; the agent-map pane). |
| Refusals | agent.spawn refuses a spawn past a cap (or asks, with an override marker); tool.call refuses a muted agent's calls; config.set refuses an out-of-range cap setting. |
Calls ($.…) | agent.list; clock (after, every, now); command.register; config.list; env.get; fs.read; session (messages, usage); state (get, set); store (get, set); tool (call, check); ui (ask, close, open, resolve, toast). |
| State | 17 atoms under lk-agent-map. |
| Store | band, caps, view2. |
| Env | Reads HOME; writes nothing. |
What it runs:
~/.claude/agent-map.json and <project>/.claude/agent-map.json; writes nothing.TaskStop, only when you press a row's Stop twice (arm, then confirm), with your press as the consent.The directory's validator listed these items for this plugin. Each bullet says what the code does and where (hooks/register.ts unless named).
MOD_CALLS_TOOLS, MOD_CAN_FETCH_AND_RUN). stopAgent() (line 689) calls Claude Code's own TaskStop tool, $.tool.call({ tool: 'TaskStop', task_id, consent }), and no other tool. It runs only when the user presses a running agent's Stop in the pane twice (the first press arms it, the second confirms). TaskStop stops that agent by its id; it fetches nothing and runs no command. The consent is a sentence built in the code, The user pressed "Stop" on the "<agent type>" agent <id> in the agent map pane. When TaskStop cannot stop the agent, the plugin mutes it instead: the tool.call hook refuses that agent's further tool calls with a message that asks it to return its result now. The plugin starts no programs and has nohooks/register.ts 1299 lines1// agent-map: a live map of the session's subagents and how they connect,
2// and (0.3.0) a deck that caps how many agents a session spawns, tracks what
3// their results cost the main context, and logs each agent's lifecycle.
4//
5// agent.spawn refuses a spawn past a cap (concurrent, per turn, per session)
6// unless the person allows it from a `[cap-override: <reason>]` ask.
7//
8// Every hook here observes and passes its event on, with two exceptions the
9// person asks for: the pane's Stop button (TaskStop, on a confirmed second
10// press) and, when TaskStop can't stop an agent, the mute that refuses that
11// agent's further tool calls. The data a drawing
12// depends on lives in $.state (it survives a hot reload); the band's on/off
13// switch is also kept in $.store so it lasts between sessions.
14//
15// 0.4.0: the pane opens on the Agents cards (hooks/pane.ts, drawn with the kit):
16// session tiles (cost, tokens, time), one card per agent with its role doodle, model and
17// effort, TodoWrite progress, ctx %, tokens, cost and elapsed time, and the planned spawns.
18import { atom, read, update } from 'claude-code'
19import type { Register } from 'claude-code'
20
21import type { AgentRecord, CapConfig, CapCounters, CapSources, LogEntry, LogKind, PaneView, PendingCall, PriceRow } from '../types'
22import {
23 ASK_ALLOW_ONE,
24 ASK_ALLOW_TURN,
25 ASK_DENY,
26 CAP_USAGE,
27 DEFAULT_CAPS,
28 DEFAULT_SOURCES,
29 MAIN,
30 SETTING_KEY,
31 addLog,
32 addReturned,
33 askText,
34 capBreach,
35 capKeyOf,
36 capMaxOf,
37 capNear,
38 capUses,
39 denyText,
40 estimateTokens,
41 layerCaps,
42 mergeCaps,
43 nearText,
44 newTurn,
45 overrideReason,
46 parseCapCommand,
47 parseCapFile,
48 parseCapReset,
49 resultText,
50 runningCount,
51 settingProblem,
52 settingValues,
53 settingsCaps,
54 stripOverride,
55 totalTokens,
56 zeroCounters,
57 MUTE_REASON,
58 activityOf,
59 activityOfMessages,
60 effortOf,
61 todoOf,
62 addEdge,
63 addWaiting,
64 addPending,
65 anyRunning,
66 applyComplete,
67 applySpawn,
68 applyStep,
69 applyToolCall,
70 clearFinished,
71 clearWaiting,
72 isStuck,
73 idleMs,
74 markFailed,
75 markMuted,
76 markStopped,
77 reconcile,
78 pendingOf,
79 resolveAddress,
80 resume,
81 spawnFacts,
82 statusOfComplete,
83 str,
84 takePending,
85 withAgent,
86 withOrder,
87} from './model'
88import type { Agents, CapCommandKind, ListedAgent } from './model'
89import {
90 DEFAULT_STUCK_MS,
91 bandOf,
92 buildRows,
93 cardsText,
94 cut,
95 fmtElapsed,
96 fmtIdle,
97 fmtTokens,
98 elapsedOf,
99 labelOf,
100 logRows,
101 logText,
102 returnedNear,
103 returnedNote,
104 teamRows,
105 teamText,
106 treeText,
107} from './format'
108import type { Row } from './format'
109import { TONES, makeKit } from './kit'
110import type { Elements } from './kit'
111import { DEFAULT_WINDOW, capsTree, cardsTree, headerTree, mainRow } from './pane'
112import { PRICES, mergePrices } from './prices'
113
114const PANE = 'agent-map'
115const TITLE = 'Agent map'
116const COMMAND = 'agent-map'
117const BAND_KEY = 'band'
118
119const agentsAtom = atom({ plugin: 'lk-agent-map', key: 'agents' } as const, {} as Record<string, AgentRecord>)
120const orderAtom = atom({ plugin: 'lk-agent-map', key: 'order' } as const, [] as string[])
121const pendingAtom = atom({ plugin: 'lk-agent-map', key: 'pending' } as const, [] as PendingCall[])
122const expandedAtom = atom({ plugin: 'lk-agent-map', key: 'expanded' } as const, [] as string[])
123const nowAtom = atom({ plugin: 'lk-agent-map', key: 'now' } as const, 0)
124const bandAtom = atom({ plugin: 'lk-agent-map', key: 'band' } as const, true)
125const everAtom = atom({ plugin: 'lk-agent-map', key: 'everSpawned' } as const, false)
126const armedAtom = atom({ plugin: 'lk-agent-map', key: 'armed' } as const, null as string | null)
127const capsAtom = atom({ plugin: 'lk-agent-map', key: 'caps' } as const, DEFAULT_CAPS as CapConfig)
128const capSourcesAtom = atom({ plugin: 'lk-agent-map', key: 'capSources' } as const, DEFAULT_SOURCES as CapSources)
129const countersAtom = atom({ plugin: 'lk-agent-map', key: 'counters' } as const, zeroCounters() as CapCounters)
130const logAtom = atom({ plugin: 'lk-agent-map', key: 'log' } as const, [] as LogEntry[])
131const viewAtom = atom({ plugin: 'lk-agent-map', key: 'view' } as const, null as PaneView | null)
132const showLogAtom = atom({ plugin: 'lk-agent-map', key: 'showLog' } as const, false)
133const collapsedAtom = atom({ plugin: 'lk-agent-map', key: 'collapsed' } as const, false)
134const windowAtom = atom({ plugin: 'lk-agent-map', key: 'window' } as const, DEFAULT_WINDOW)
135const pricesAtom = atom({ plugin: 'lk-agent-map', key: 'prices' } as const, PRICES as PriceRow[])
136
137const CAPS_KEY = 'caps'
138// 0.5.0 reads the chosen view from a new key, so everyone lands on cards once; later choices are kept
139const VIEW_KEY = 'view2'
140const CAP_FILE = '.claude/agent-map.json'
141const VIEWS: readonly PaneView[] = ['tree', 'cards', 'team']
142
143/** How long an armed Stop button waits for its second press. */
144export const ARM_MS = 5000
145/** Room the pane keeps on a running row for its Stop button. */
146const STOP_RESERVE = 16
147/** An agent's own transcript is read for its last activity at most this often. */
148const PEEK_MS = 10_000
149const STOPPED_BY_YOU = 'you (agent map)'
150
151type Config = { stuckAfterMs: number }
152export function parseConfig(options: unknown): Config {
153 const o = (options && typeof options === 'object' ? options : {}) as Record<string, unknown>
154 const v = o.stuckAfterMs
155 const stuckAfterMs = typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.round(v) : DEFAULT_STUCK_MS
156 return { stuckAfterMs }
157}
158let cfg: Config = parseConfig(undefined)
159// The values register(on, options) got: the cap settings when $.config.list() can't be read.
160let regOptions: unknown
161
162// tool.check carries no loop id; the tool.call around it does, keyed by the call's id.
163const callLoop = new Map<string, string>()
164// TaskStop calls this mod raised itself, by task id: not counted as the main loop's tools.
165const ownStops = new Set<string>()
166// When each agent's transcript was last read for its activity.
167const peekedAt = new Map<string, number>()
168let armTimer: { cancel: () => void } | null = null
169// Spawns the cap gate admitted that agent.spawn hasn't recorded yet, by tool_use_id: they count as running.
170const inflight = new Set<string>()
171// The cap note each admitted spawn logs with ("running 2/4 · turn 1/8 · session 1/40").
172const spawnNotes = new Map<string, string>()
173// Agent tool calls and the agents they started, both ways, so a result finds its agent whichever comes first.
174const agentOfCall = new Map<string, string>()
175const returnedOfCall = new Map<string, number>()
176let spawnSeq = 0
177// Permission asks not yet shown as waiting: an auto-approved ask resolves inside the grace and never flickers.
178type PendingAsk = { loop: string; tool: string; timer: { cancel: () => void } | null }
179const pendingAsks = new Map<string, PendingAsk>()
180// Once classic.PermissionRequest has fired in this session, a dialog is the signal and the grace timer only stands by.
181let dialogSignal = false
182// The session's working directory, for <cwd>/.claude/agent-map.json
183let sessionCwd: string | undefined
184// The cap gate runs one spawn at a time, so parallel spawns can't all pass the same count.
185let gateChain: Promise<unknown> = Promise.resolve()
186function serialized<T>(fn: () => Promise<T>): Promise<T> {
187 const run = gateChain.then(fn, fn)
188 gateChain = run.catch(() => undefined)
189 return run
190}
191
192// The one-second redraw timer. Timers stop when the module reloads, so this
193// is module state on purpose: a fresh module starts its own when needed.
194let ticker: { cancel: () => void } | null = null
195
196type Api = Parameters<typeof read>[0]
197
198async function nowOf($: Api): Promise<number> {
199 try {
200 const t = await $.clock.now()
201 return typeof t === 'number' ? t : Date.now()
202 } catch {
203 return Date.now()
204 }
205}
206
207async function tick($: Api): Promise<void> {
208 try {
209 const t = await $.clock.now()
210 await update($, nowAtom, () => t)
211 await reconcileList($)
212 await alertStuck($, t)
213 if (!anyRunning(await read($, agentsAtom)) && ticker) {
214 ticker.cancel()
215 ticker = null
216 }
217 } catch {
218 // A failed tick waits for the next one
219 }
220}
221
222/** Starts the redraw timer while anything runs, and stops it when nothing does. */
223async function syncTicker($: Api): Promise<void> {
224 try {
225 const running = anyRunning(await read($, agentsAtom))
226 if (running && !ticker) {
227 ticker = $.clock.every(1000, () => {
228 void tick($)
229 })
230 } else if (!running && ticker) {
231 ticker.cancel()
232 ticker = null
233 }
234 } catch {
235 ticker = null
236 }
237}
238
239/** Toasts once per stall for each running agent idle past stuckAfterMs. */
240async function alertStuck($: Api, now: number): Promise<void> {
241 if (cfg.stuckAfterMs <= 0) return
242 const agents = await read($, agentsAtom)
243 const stuck = Object.values(agents).filter(a => a.id !== MAIN && !a.stuckNotified && isStuck(a, now, cfg.stuckAfterMs))
244 if (stuck.length === 0) return
245 await update($, agentsAtom, (m: Agents) => {
246 let out = m
247 for (const a of stuck) if (out[a.id]) out = { ...out, [a.id]: { ...out[a.id], stuckNotified: true } }
248 return out
249 })
250 for (const a of stuck) {
251 await logEvent($, a.id, 'idle', 'no tool call or model request for ' + fmtIdle(idleMs(a, now))).catch(() => undefined)
252 try {
253 await $.ui.toast('Agent ' + cut(labelOf(a), 40) + ' has been idle ' + fmtIdle(idleMs(a, now)) + ': no tool call or model request')
254 } catch {
255 // The row still shows ⚠ idle
256 }
257 }
258}
259
260/**
261 * Merges $.agent.list() into the map, when this build has it: agents the
262 * spawn hook missed (forks, teammates, workflow agents) appear, parents and
263 * end states fill in. An agent with no activity yet gets its transcript's tail.
264 */
265async function reconcileList($: Api): Promise<void> {
266 let listed: unknown
267 try {
268 listed = await $.agent.list()
269 } catch {
270 // A build or host without the list: the spawn hook's map stands
271 return
272 }
273 if (!Array.isArray(listed) || listed.length === 0) return
274 const now = await nowOf($)
275 const order = await read($, orderAtom)
276 let added: string[] = []
277 let nextOrder = order
278 await update($, agentsAtom, (m: Agents) => {
279 const r = reconcile(m, order, listed as ListedAgent[], now)
280 added = r.added
281 nextOrder = r.order
282 return r.agents
283 })
284 if (nextOrder !== order) await update($, orderAtom, () => nextOrder)
285 if (added.length > 0) {
286 await openOnFirstSpawn($)
287 await syncTicker($)
288 }
289 await peekActivity($, now)
290}
291
292/** The last activity of running agents no tool.call was seen from, from their own transcripts. */
293async function peekActivity($: Api, now: number): Promise<void> {
294 const agents = await read($, agentsAtom)
295 for (const a of Object.values(agents)) {
296 if (a.id === MAIN || !a.isSpawned || a.status !== 'running' || a.activity) continue
297 if (now - (peekedAt.get(a.id) ?? -Infinity) < PEEK_MS) continue
298 peekedAt.set(a.id, now)
299 try {
300 const activity = activityOfMessages(await $.session.messages({ agentId: a.id }))
301 if (activity) await update($, agentsAtom, (m: Agents) => (m[a.id] && !m[a.id].activity ? { ...m, [a.id]: { ...m[a.id], activity } } : m))
302 } catch {
303 // A transcript this session can't read: the row shows no activity
304 }
305 }
306}
307
308async function changeAgent($: Api, id: string, fn: (a: AgentRecord, now: number) => AgentRecord): Promise<void> {
309 const now = await nowOf($)
310 await update($, agentsAtom, (m: Agents) => withAgent(m, id, now, a => fn(a, now)))
311 await update($, nowAtom, () => now)
312}
313
314/** Shows ⏸ waiting for a pending ask now (its grace ran out, or a dialog opened for it). */
315// An ask waits out its grace: when the grace runs out it shows ⏸ waiting, unless a dialog signal says it was approved.
316function armGrace($: Api, toolUseId: string, grace: number): void {
317 const p = pendingAsks.get(toolUseId)
318 if (!p) return
319 p.timer = $.clock.after(grace, () => {
320 if (p.timer === null) return
321 p.timer = null
322 // With a dialog signal in this session, an ask the grace outlived without a dialog was approved: it runs
323 if (dialogSignal) pendingAsks.delete(toolUseId)
324 else void markWaiting($, toolUseId).catch(() => undefined)
325 })
326}
327
328// The call a settings PreToolUse point saw is going to run: its pending ask is dropped and a shown ⏸ waiting clears.
329async function clearApprovedWait($: Api, e: unknown): Promise<void> {
330 let toolUseId = str(e, 'tool_use_id')
331 if (!toolUseId) {
332 const tool = str(e, 'tool_name', 'tool')
333 for (const [k, p] of pendingAsks) if (p.tool === tool && p.loop === (str(e, 'agentId', 'agent_id') ?? MAIN)) toolUseId = k
334 }
335 if (!toolUseId) return
336 const id = toolUseId
337 cancelAsk(id)
338 let waited: string[] = []
339 await update($, agentsAtom, (m: Agents) => {
340 waited = Object.values(m).filter(a => a.waiting?.some(w => w.toolUseId === id)).map(a => a.id)
341 return clearWaiting(m, id)
342 })
343 for (const w of waited) await logEvent($, w, 'resumed', 'approved: ' + (str(e, 'tool', 'tool_name') ?? 'tool'))
344}
345
346async function markWaiting($: Api, toolUseId: string): Promise<void> {
347 const p = pendingAsks.get(toolUseId)
348 if (!p) return
349 pendingAsks.delete(toolUseId)
350 p.timer?.cancel()
351 const a = (await read($, agentsAtom))[p.loop]
352 if (p.loop !== MAIN && a && a.status !== 'running') return
353 await changeAgent($, p.loop, (x, now) => addWaiting(x, { toolUseId, tool: p.tool, at: now }))
354 await logEvent($, p.loop, 'waiting', p.tool)
355}
356
357function cancelAsk(toolUseId: string): void {
358 const p = pendingAsks.get(toolUseId)
359 if (!p) return
360 p.timer?.cancel()
361 pendingAsks.delete(toolUseId)
362}
363
364function cancelAsksOf(loop: string): void {
365 for (const [k, p] of pendingAsks) if (p.loop === loop) cancelAsk(k)
366}
367
368/** Appends one lifecycle event of agent `id` to the log. */
369async function logEvent($: Api, id: string, kind: LogKind, text: string, label?: string): Promise<void> {
370 const now = await nowOf($)
371 const agents = await read($, agentsAtom)
372 const name = label ?? (id === MAIN ? 'main' : cut(labelOf(agents[id], id), 32))
373 await update($, logAtom, (l: LogEntry[]) => addLog(l, { at: now, id, label: name, kind, text }))
374}
375
376async function readText($: Api, path: string): Promise<string | undefined> {
377 try {
378 const t = await $.fs.read(path)
379 return typeof t === 'string' ? t : undefined
380 } catch {
381 return undefined
382 }
383}
384
385/**
386 * The caps: defaults, then the plugin's settings (its /config rows, read live), then
387 * ~/.claude/agent-map.json, then <cwd>/.claude/agent-map.json, then what /agent-map cap saved.
388 * The same files' `prices` objects lay over the price table by family.
389 */
390async function loadCaps($: Api): Promise<void> {
391 let prices: PriceRow[] = PRICES.map(r => [...r] as PriceRow)
392 let rows: unknown
393 try {
394 rows = await $.config.list()
395 } catch {
396 // No rows: the values register got stand
397 }
398 let home: string | undefined
399 try {
400 home = (await $.env.get('HOME')) || undefined
401 } catch {
402 home = undefined
403 }
404 const userFile = home ? parseCapFile(await readText($, home.replace(/\/+$/, '') + '/' + CAP_FILE)) : undefined
405 const projectFile = parseCapFile(await readText($, sessionCwd ? sessionCwd.replace(/\/+$/, '') + '/' + CAP_FILE : CAP_FILE))
406 for (const f of [userFile, projectFile]) {
407 if (f && typeof f === 'object') prices = mergePrices(prices, (f as Record<string, unknown>).prices)
408 }
409 let saved: unknown
410 try {
411 saved = await $.store.get(CAPS_KEY)
412 } catch {
413 // No store: the files, settings and defaults stand
414 }
415 const { caps, sources } = layerCaps([
416 { source: 'settings', over: settingsCaps(settingValues(rows, regOptions)) },
417 { source: 'user file', over: userFile },
418 { source: 'project file', over: projectFile },
419 { source: 'saved (/agent-map cap)', over: saved },
420 ])
421 await update($, capsAtom, () => caps)
422 await update($, capSourcesAtom, () => sources)
423 await update($, pricesAtom, () => prices)
424}
425
426/** The session's context window, which the cards' ctx % divides by (a subagent's own isn't exposed). */
427async function loadWindow($: Api): Promise<void> {
428 try {
429 const u = (await $.session.usage()) as { context?: { window?: unknown } } | undefined
430 const w = u?.context?.window
431 if (typeof w === 'number' && Number.isFinite(w) && w > 0) await update($, windowAtom, () => w)
432 } catch {
433 // Keep the 200k fallback
434 }
435}
436
437/** Drops an Agent call that will never spawn (refused by the gate or the engine) from the planned list. */
438async function dropPending($: Api, toolUseId: string | undefined): Promise<void> {
439 if (!toolUseId) return
440 await update($, pendingAtom, (l: PendingCall[]) => (l.some(p => p.toolUseId === toolUseId) ? l.filter(p => p.toolUseId !== toolUseId) : l))
441}
442
443async function loadView($: Api): Promise<void> {
444 try {
445 const v = await $.store.get(VIEW_KEY)
446 if (typeof v === 'string' && (VIEWS as readonly string[]).includes(v)) await update($, viewAtom, () => v as PaneView)
447 } catch {
448 // Keep the default: cards in the pane, the tree in /agent-map text
449 }
450}
451
452async function setView($: Api, v: PaneView): Promise<void> {
453 await update($, viewAtom, () => v)
454 try {
455 await $.store.set(VIEW_KEY, v)
456 } catch {
457 // Kept for this session only
458 }
459}
460
461/**
462 * The cap check for one spawn. Returns a refusal, or null when the spawn may
463 * start (it then counts as running until agent.spawn records it).
464 */
465async function gate($: Api, e: Record<string, unknown>, key: string): Promise<string | null> {
466 const countsTurn = e.isTeammate !== true && e.fork !== true
467 const caps = await read($, capsAtom)
468 const counters = await read($, countersAtom)
469 const agents = await read($, agentsAtom)
470 const running = runningCount(agents) + inflight.size
471 const uses = capUses(running, counters, caps)
472 const label = cut(stripOverride(str(e, 'description', 'name') ?? 'agent'), 32) || 'agent'
473 const breach = capBreach(uses, countsTurn)
474 if (breach) {
475 let allowed = counters.overrideTurn
476 let why = ''
477 if (allowed) {
478 await logEvent($, key, 'override', 'past the ' + breach.kind + ' cap (' + breach.n + '/' + breach.max + '): allowed for the rest of the turn', label)
479 } else {
480 const reason = overrideReason(e.description, e.prompt)
481 if (!reason) {
482 why = ''
483 } else {
484 let answer: string | undefined
485 try {
486 answer = await $.ui.ask(askText(reason), [ASK_ALLOW_ONE, ASK_ALLOW_TURN, ASK_DENY])
487 } catch {
488 answer = undefined
489 }
490 if (answer === ASK_ALLOW_ONE || answer === ASK_ALLOW_TURN) {
491 allowed = true
492 if (answer === ASK_ALLOW_TURN) await update($, countersAtom, (c: CapCounters) => ({ ...c, overrideTurn: true }))
493 } else {
494 why = answer === undefined ? ' No one could be asked about the override.' : ' The user declined the override.'
495 }
496 await logEvent($, key, 'override', reason + ': ' + (answer === undefined ? 'no one to ask, denied' : answer), label)
497 }
498 }
499 if (!allowed) {
500 await update($, countersAtom, (c: CapCounters) => ({ ...c, deniedThisTurn: true }))
501 await logEvent($, key, 'denied', breach.kind + ' cap ' + breach.n + '/' + breach.max, label)
502 return denyText(breach) + why
503 }
504 }
505 inflight.add(key)
506 let after = counters
507 await update($, countersAtom, (c: CapCounters) => {
508 after = { ...c, turn: c.turn + (countsTurn ? 1 : 0), session: c.session + 1 }
509 return after
510 })
511 spawnNotes.set(
512 key,
513 'running ' + (running + 1) + capOf(caps.maxConcurrent) + ' · turn ' + after.turn + capOf(caps.maxPerTurn) + ' · session ' + after.session + capOf(caps.maxPerSession) + (countsTurn ? '' : ' · not counted per turn'),
514 )
515 const near = capNear(uses, caps.warnAt, countsTurn)
516 if (near && !after.warnedThisTurn) {
517 await update($, countersAtom, (c: CapCounters) => ({ ...c, warnedThisTurn: true }))
518 try {
519 await $.ui.toast(nearText(near))
520 } catch {
521 // The band's chip says near cap anyway
522 }
523 }
524 return null
525}
526
527const capOf = (max: number) => (max > 0 ? '/' + max : '')
528
529/** A spawn that next() refused or that never started gives back what the gate counted. */
530async function ungate($: Api, key: string, e: Record<string, unknown>): Promise<void> {
531 const countsTurn = e.isTeammate !== true && e.fork !== true
532 inflight.delete(key)
533 spawnNotes.delete(key)
534 await update($, countersAtom, (c: CapCounters) => ({ ...c, turn: Math.max(0, c.turn - (countsTurn ? 1 : 0)), session: Math.max(0, c.session - 1) }))
535}
536
537/** What an Agent tool call's result put into its caller's context (characters / 4). */
538async function noteReturned($: Api, toolUseId: string, caller: string, tokens: number): Promise<void> {
539 if (tokens <= 0) return
540 const agentId = agentOfCall.get(toolUseId)
541 if (agentId) {
542 await changeAgent($, agentId, a => addReturned(a, tokens))
543 await logEvent($, agentId, 'returned', fmtTokens(tokens) + ' tok to ' + (caller === MAIN ? 'main' : cut(labelOf((await read($, agentsAtom))[caller], caller), 24)))
544 } else {
545 returnedOfCall.set(toolUseId, (returnedOfCall.get(toolUseId) ?? 0) + tokens)
546 }
547 if (caller === MAIN) await addToMain($, tokens)
548}
549
550async function addToMain($: Api, tokens: number): Promise<void> {
551 const caps = await read($, capsAtom)
552 let after = await read($, countersAtom)
553 await update($, countersAtom, (c: CapCounters) => {
554 after = { ...c, returnedTotal: c.returnedTotal + tokens }
555 return after
556 })
557 if (!after.returnedWarned && returnedNear(caps, after)) {
558 await update($, countersAtom, (c: CapCounters) => ({ ...c, returnedWarned: true }))
559 try {
560 await $.ui.toast(
561 'agent-map: subagent results have put ' + fmtTokens(after.returnedTotal) + ' tokens into the main context. Consider /compact, or a handoff to a fresh session.',
562 )
563 } catch {
564 // The band's chip says near cap
565 }
566 }
567}
568
569async function loadBand($: Api): Promise<void> {
570 try {
571 const saved = await $.store.get(BAND_KEY)
572 if (typeof saved === 'boolean') await update($, bandAtom, () => saved)
573 } catch {
574 // No store: keep the default
575 }
576}
577
578async function openOnFirstSpawn($: Api): Promise<void> {
579 if (await read($, everAtom)) return
580 await update($, everAtom, () => true)
581 try {
582 const placed = await $.ui.open({ id: PANE, title: TITLE })
583 if (!placed || placed.isPlaced !== true) await $.ui.toast('Subagents started: /agent-map shows the map')
584 } catch {
585 // A pane that can't open leaves the band and /agent-map text
586 }
587}
588
589async function recordSpawn($: Api, e: unknown, r: unknown, threw = false, gateKey?: string): Promise<void> {
590 if (r && typeof r === 'object' && (r as { deny?: unknown }).deny) return
591 let match: PendingCall | undefined
592 await update($, pendingAtom, (list: PendingCall[]) => {
593 const taken = takePending(list, e)
594 match = taken.match
595 return taken.rest
596 })
597 const facts = spawnFacts(e, r, match)
598 const toolUseId = str(e, 'tool_use_id')
599 const key = gateKey ?? toolUseId ?? ''
600 const early = toolUseId ? (returnedOfCall.get(toolUseId) ?? 0) : 0
601 await changeAgent($, facts.id, (a, now) => {
602 let x = threw ? markFailed(applySpawn(a, facts, now), now) : applySpawn(a, facts, now)
603 if (early > 0) x = addReturned(x, early)
604 return x
605 })
606 // Recorded: it counts through the map now, no longer as in flight
607 inflight.delete(key)
608 if (toolUseId) {
609 agentOfCall.set(toolUseId, facts.id)
610 returnedOfCall.delete(toolUseId)
611 }
612 const a = (await read($, agentsAtom))[facts.id]
613 const note = spawnNotes.get(key)
614 spawnNotes.delete(key)
615 await logEvent($, facts.id, 'spawned', [a ? (a.name || 'agent') + ' ' + (a.model || '?') : '', note ?? '', threw ? 'failed to start' : ''].filter(Boolean).join(' · '))
616 await update($, orderAtom, (o: string[]) => withOrder(o, facts.id))
617 await openOnFirstSpawn($)
618 await syncTicker($)
619}
620
621async function recordComplete($: Api, e: unknown, failed: boolean): Promise<void> {
622 const id = str(e, 'agentId')
623 cancelAsksOf(id ?? MAIN)
624 if (!id) {
625 const now = await nowOf($)
626 await update($, nowAtom, () => now)
627 return
628 }
629 const status = failed ? 'failed' : statusOfComplete(e)
630 const wasRunning = (await read($, agentsAtom))[id]?.status === 'running'
631 const prices = await read($, pricesAtom)
632 await changeAgent($, id, (a, now) => (failed ? markFailed(a, now) : applyComplete(a, e, now, status, prices)))
633 const a = (await read($, agentsAtom))[id]
634 if (wasRunning && a && a.isSpawned && a.status !== 'running') {
635 const ret = returnedNote(await read($, agentsAtom), a)
636 const kind: LogKind = a.status === 'done' || a.status === 'failed' || a.status === 'aborted' ? a.status : 'stopped'
637 await logEvent($, id, kind, 'in ' + fmtElapsed(elapsedOf(a, await nowOf($))) + ' · ' + fmtTokens(totalTokens(a.tokens)) + ' tok' + (ret ? ' · ' + ret : ''))
638 }
639 await syncTicker($)
640}
641
642async function recordMessage($: Api, fromRaw: unknown, toRaw: unknown): Promise<void> {
643 const agents = await read($, agentsAtom)
644 const from = resolveAddress(agents, fromRaw) ?? MAIN
645 const to = resolveAddress(agents, toRaw)
646 if (!to || to === from) return
647 const isSub = (id: string) => id !== MAIN && agents[id]?.isSpawned === true
648 if (!isSub(from) && !isSub(to)) return
649 const now = await nowOf($)
650 await update($, agentsAtom, (m: Agents) => addEdge(withAgent(m, MAIN, now, a => a), from, to, now))
651}
652
653async function clearFinishedAgents($: Api): Promise<number> {
654 let removed = 0
655 const order = await read($, orderAtom)
656 let nextOrder = order
657 await update($, agentsAtom, (m: Agents) => {
658 const c = clearFinished(m, order)
659 removed = c.removed
660 nextOrder = c.order
661 return c.agents
662 })
663 await update($, orderAtom, () => nextOrder)
664 const agents = await read($, agentsAtom)
665 await update($, expandedAtom, (x: string[]) => x.filter(id => agents[id] !== undefined))
666 return removed
667}
668
669async function toggleExpanded($: Api, id: string): Promise<void> {
670 await update($, expandedAtom, (x: string[]) => (x.includes(id) ? x.filter(y => y !== id) : [...x, id]))
671}
672
673async function setBand($: Api, value: boolean): Promise<void> {
674 await update($, bandAtom, () => value)
675 try {
676 await $.store.set(BAND_KEY, value)
677 } catch {
678 // Kept for this session only
679 }
680}
681
682async function mapText($: Api): Promise<string> {
683 const agents = await read($, agentsAtom)
684 const order = await read($, orderAtom)
685 return treeText(agents, order, await nowOf($), undefined, cfg.stuckAfterMs)
686}
687
688/** First press arms the row's Stop button; a second press within ARM_MS stops the agent. */
689async function pressStop($: Api, id: string): Promise<void> {
690 const armed = await read($, armedAtom)
691 if (armed === id) {
692 armTimer?.cancel()
693 armTimer = null
694 await update($, armedAtom, () => null)
695 await stopAgent($, id)
696 return
697 }
698 await update($, armedAtom, () => id)
699 armTimer?.cancel()
700 armTimer = $.clock.after(ARM_MS, () => {
701 armTimer = null
702 void update($, armedAtom, (x: string | null) => (x === id ? null : x)).catch(() => undefined)
703 })
704}
705
706/**
707 * Stops a running agent with Claude Code's own TaskStop, raised with the
708 * person's press as consent. When TaskStop can't (a foreground subagent, an
709 * id it doesn't know), the agent is muted instead: its next tool calls are
710 * refused with a reason that asks it to return its result now.
711 */
712async function stopAgent($: Api, id: string): Promise<void> {
713 const a = (await read($, agentsAtom))[id]
714 if (!a || a.status !== 'running') return
715 // The consent reads to the engine as the person's words: the type and id, never a model-written description.
716 const consent = 'The user pressed "Stop" on the ' + JSON.stringify(cut(a.name || 'agent', 30)) + ' agent ' + cut(id, 60) + ' in the agent map pane'
717 ownStops.add(id)
718 let r: { deny?: string; isError?: boolean; text?: unknown } | undefined
719 try {
720 r = (await $.tool.call({ tool: 'TaskStop', task_id: id, consent } as never)) as typeof r
721 } catch (err) {
722 r = { deny: String(err instanceof Error ? err.message : err) }
723 } finally {
724 ownStops.delete(id)
725 }
726 const why = r?.deny || (r?.isError ? String(r.text ?? '').replace(/\s+/g, ' ').trim() || 'the tool reported an error' : '')
727 if (!why) {
728 const was = (await read($, agentsAtom))[id]?.status
729 await changeAgent($, id, (x, now) => markStopped(x, STOPPED_BY_YOU, now))
730 // noteStop may have logged it already, from inside the TaskStop call
731 if (was === 'running') await logEvent($, id, 'stopped', 'by ' + STOPPED_BY_YOU)
732 await syncTicker($)
733 return
734 }
735 await changeAgent($, id, x => markMuted(x))
736 await logEvent($, id, 'muted', 'TaskStop refused: ' + cut(why, 60))
737 try {
738 await $.ui.toast('Could not stop ' + cut(labelOf(a), 40) + ' (' + cut(why, 80) + '). Muted it: its next tool calls are refused.')
739 } catch {
740 // The row shows ⊘ muted
741 }
742}
743
744/** A TaskStop that went through: the agent it named is stopped, by whoever called it. */
745async function noteStop($: Api, e: unknown, r: unknown, caller: string): Promise<void> {
746 const res = (r && typeof r === 'object' ? r : {}) as { deny?: unknown; isError?: unknown }
747 if (res.deny || res.isError) return
748 const agents = await read($, agentsAtom)
749 const target = resolveAddress(agents, str(e, 'task_id', 'shell_id'))
750 if (!target || target === MAIN || agents[target]?.status !== 'running') return
751 const by = ownStops.has(target) ? STOPPED_BY_YOU : caller === MAIN ? 'main' : labelOf(agents[caller], caller)
752 await changeAgent($, target, (x, now) => markStopped(x, by, now))
753 await logEvent($, target, 'stopped', 'by ' + by)
754}
755
756const USAGE = 'Usage: /agent-map [clear | band on | band off | text | cards | tree | team | view <cards|tree|team> | log | cap <kind> <n> | cap reset [kind]]'
757
758function capsText(c: CapConfig, counters: CapCounters, running: number): string {
759 const v = (n: number) => (n > 0 ? String(n) : 'off')
760 return (
761 'agent-map caps: concurrent ' + v(c.maxConcurrent) + ' (now ' + running + ') · turn ' + v(c.maxPerTurn) + ' (now ' + counters.turn + ') · session ' +
762 v(c.maxPerSession) + ' (now ' + counters.session + ') · returned ' + (c.maxReturnedTokens > 0 ? fmtTokens(c.maxReturnedTokens) : 'off') +
763 ' (now ' + fmtTokens(counters.returnedTotal) + ') · warn at ' + Math.round(c.warnAt * 100) + '% · waiting grace ' + c.waitGraceMs + ' ms'
764 )
765}
766
767/** One cap's value as /agent-map cap says it: `90%`, `200k tok`, `1500 ms`, `6`; `off` for 0. */
768function shownCap(caps: CapConfig, kind: CapCommandKind): string {
769 if (kind === 'warn') return Math.round(caps.warnAt * 100) + '%'
770 if (kind === 'grace') return caps.waitGraceMs === 0 ? '0 ms (waits show at once)' : caps.waitGraceMs + ' ms'
771 const n = caps[capKeyOf(kind)]
772 if (n === 0) return 'off'
773 return kind === 'returned' ? fmtTokens(n) + ' tok' : String(n)
774}
775
776async function savedCaps($: Api): Promise<Record<string, unknown>> {
777 try {
778 const s = await $.store.get(CAPS_KEY)
779 if (s && typeof s === 'object') return { ...(s as Record<string, unknown>) }
780 } catch {
781 // Start a fresh override set
782 }
783 return {}
784}
785
786/** `/agent-map cap reset [kind]`: drops that kind's saved override (every one without a kind) and loads the caps again. */
787async function resetCap($: Api, args: string[]): Promise<string> {
788 const r = parseCapReset(args)
789 if ('error' in r) return r.error
790 let saved = await savedCaps($)
791 if (r.key) delete saved[r.key]
792 else saved = {}
793 try {
794 await $.store.set(CAPS_KEY, saved)
795 } catch {
796 // Nothing saved to clear
797 }
798 await loadCaps($).catch(() => undefined)
799 const caps = await read($, capsAtom)
800 if (!r.kind || !r.key) return 'agent-map: saved caps cleared. ' + capsText(caps, await read($, countersAtom), runningCount(await read($, agentsAtom)))
801 return 'agent-map: ' + r.kind + ' cap reset to ' + shownCap(caps, r.kind) + ' (' + (await read($, capSourcesAtom))[r.key] + ')'
802}
803
804async function setCap($: Api, args: string[]): Promise<string> {
805 if (args.length === 0) return capsText(await read($, capsAtom), await read($, countersAtom), runningCount(await read($, agentsAtom)))
806 if (args[0] === 'reset') return resetCap($, args)
807 const cmd = parseCapCommand(args)
808 if ('error' in cmd) return cmd.error
809 const key = capKeyOf(cmd.kind)
810 const saved = await savedCaps($)
811 saved[key] = cmd.value
812 try {
813 await $.store.set(CAPS_KEY, saved)
814 } catch {
815 // Kept for this session only
816 }
817 await update($, capsAtom, (c: CapConfig) => mergeCaps(c, { [key]: cmd.value }))
818 await update($, capSourcesAtom, (s: CapSources) => ({ ...s, [key]: 'saved (/agent-map cap)' }))
819 return 'agent-map: ' + cmd.kind + ' cap set to ' + shownCap(await read($, capsAtom), cmd.kind)
820}
821
822async function viewText($: Api, view: PaneView): Promise<string> {
823 const agents = await read($, agentsAtom)
824 const order = await read($, orderAtom)
825 const now = await nowOf($)
826 if (view === 'cards') return cardsText(agents, order, now, undefined, cfg.stuckAfterMs)
827 if (view === 'team') return teamText(agents, order, now, undefined, cfg.stuckAfterMs)
828 return treeText(agents, order, now, undefined, cfg.stuckAfterMs)
829}
830
831export const register: Register = (on, options) => {
832 cfg = parseConfig(options)
833 regOptions = options
834
835 on('session.start', async ($, e, next) => {
836 sessionCwd = str(e, 'cwd') ?? sessionCwd
837 await loadBand($)
838 await loadCaps($).catch(() => undefined)
839 await loadView($)
840 await loadWindow($)
841 await syncTicker($)
842 try {
843 await $.command.register({
844 name: COMMAND,
845 description: 'Open the agent deck: cards with cost, tokens, context and progress, the tree or team view, the lifecycle log, and spawn caps',
846 argumentHint: '[cards | tree | team | log | text | clear | band on|off | cap <kind> <n> | cap reset [kind]]',
847 immediate: true,
848 })
849 } catch {
850 // The name is taken: the map still draws, without the command
851 }
852 return next(e)
853 })
854
855 // /clear, /resume and /branch reset $.state: load the band switch again
856 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
857 if (ticker) {
858 ticker.cancel()
859 ticker = null
860 }
861 armTimer?.cancel()
862 armTimer = null
863 peekedAt.clear()
864 callLoop.clear()
865 for (const k of Array.from(pendingAsks.keys())) cancelAsk(k)
866 inflight.clear()
867 spawnNotes.clear()
868 agentOfCall.clear()
869 returnedOfCall.clear()
870 await loadBand($)
871 await loadCaps($).catch(() => undefined)
872 await loadView($)
873 await loadWindow($)
874 return next(e)
875 })
876
877 // A cap setting changed in /config: an out-of-range value is refused before it is written, and
878 // the caps load again once it is. A guard that fails refuses rather than letting the value through.
879 on('config.set', { key: SETTING_KEY }, async ($, e, next) => {
880 const field = SETTING_KEY.exec(e.key)?.[2]
881 const problem = field ? settingProblem(field, e.value) : undefined
882 if (problem) return { deny: problem }
883 const r = await next(e)
884 await loadCaps($).catch(() => undefined)
885 return r
886 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'agent-map could not check this value' }))
887
888 // A settings file changed outside /config (the desktop's plugin settings may write it directly)
889 on('classic.ConfigChange', async ($, e, next) => {
890 const r = await next(e)
891 await loadCaps($).catch(() => undefined)
892 return r
893 })
894
895 on('tool.call', async ($, e, next) => {
896 const id = str(e, 'agentId') ?? MAIN
897 const tool = str(e, 'tool') ?? 'tool'
898 const toolUseId = str(e, 'tool_use_id')
899 try {
900 // The mute: TaskStop couldn't stop this agent, so its tool calls end here
901 if (id !== MAIN) {
902 const a = (await read($, agentsAtom))[id]
903 if (a?.muted && a.status === 'running') return { deny: MUTE_REASON }
904 }
905 } catch {
906 // No map to read: let the call through
907 }
908 try {
909 if (toolUseId) callLoop.set(toolUseId, id)
910 const own = tool === 'TaskStop' && id === MAIN && ownStops.has(str(e, 'task_id') ?? '')
911 if (!own) {
912 if (tool === 'Agent' || tool === 'Task') {
913 const now = await nowOf($)
914 await update($, pendingAtom, (l: PendingCall[]) => addPending(l, pendingOf(e, now)))
915 }
916 const before = id === MAIN ? undefined : (await read($, agentsAtom))[id]
917 const activity = activityOf(tool, e)
918 // TodoWrite's list is the agent's progress: completed / all, and the item in progress
919 const todo = tool === 'TodoWrite' ? todoOf(e) : undefined
920 await changeAgent($, id, (a, now) => {
921 const x = applyToolCall(a, tool, activity, now)
922 return todo === undefined ? x : { ...x, todo }
923 })
924 if (before?.isSpawned && before.toolCalls === 0) await logEvent($, id, 'first-tool', activity)
925 }
926 } catch {
927 // Observing only: never stand in the tool's way
928 }
929 let r: unknown
930 try {
931 r = await next(e)
932 return r as Awaited<ReturnType<typeof next>>
933 } finally {
934 try {
935 if (toolUseId) {
936 callLoop.delete(toolUseId)
937 // An ask the call outran (auto-approved within the grace) never shows
938 cancelAsk(toolUseId)
939 // The call ran (or was refused): it no longer waits on the person.
940 // clearWaiting hands back the same map when nothing waited, so no redraw then.
941 let waited: string[] = []
942 await update($, agentsAtom, (m: Agents) => {
943 waited = Object.values(m).filter(a => a.waiting?.some(w => w.toolUseId === toolUseId)).map(a => a.id)
944 return clearWaiting(m, toolUseId)
945 })
946 for (const w of waited) await logEvent($, w, 'resumed', 'after ' + tool)
947 }
948 if ((tool === 'Agent' || tool === 'Task') && toolUseId && r) {
949 const res = r as { deny?: unknown; isError?: unknown }
950 // A refused call never spawns: it leaves the planned list
951 if (res.deny || res.isError) await dropPending($, toolUseId)
952 await noteReturned($, toolUseId, id, estimateTokens(resultText(r)))
953 }
954 if (tool === 'TaskStop') await noteStop($, e, r, id)
955 } catch {
956 // Best effort
957 }
958 }
959 })
960
961 // Permission verdicts: a call put to the person shows ⏸ waiting on its loop's row until it runs
962 on('tool.check', async ($, e, next) => {
963 try {
964 const toolUseId = str(e, 'tool_use_id')
965 const verdict = toolUseId ? await $.tool.check({ tool: e.tool, input: e.input }) : null
966 if (toolUseId && verdict && verdict.decision === 'ask') {
967 const id = callLoop.get(toolUseId) ?? str(e, 'agentId') ?? MAIN
968 const tool = str(e, 'tool') ?? 'tool'
969 const grace = (await read($, capsAtom)).waitGraceMs
970 cancelAsk(toolUseId)
971 pendingAsks.set(toolUseId, { loop: id, tool, timer: null })
972 if (grace <= 0) await markWaiting($, toolUseId)
973 else armGrace($, toolUseId, grace)
974 }
975 } catch {
976 // Observing only
977 }
978 return next(e)
979 })
980
981 on('agent.spawn', async ($, e, next) => {
982 const o = e as unknown as Record<string, unknown>
983 const key = str(o, 'tool_use_id') ?? 'spawn:' + ++spawnSeq
984 let refusal: string | null = null
985 try {
986 refusal = await serialized(() => gate($, o, key))
987 } catch {
988 // A gate that fails lets the spawn through
989 refusal = null
990 }
991 if (refusal) {
992 await dropPending($, str(o, 'tool_use_id')).catch(() => undefined)
993 return { deny: refusal }
994 }
995 // The override marker is for the gate: the subagent gets its task without it
996 let input = e
997 if (overrideReason(o.description, o.prompt)) {
998 input = { ...e, description: stripOverride(String(o.description ?? '')), prompt: stripOverride(String(o.prompt ?? '')) }
999 }
1000 let r: Awaited<ReturnType<typeof next>> | undefined
1001 let threw = true
1002 try {
1003 r = await next(input)
1004 threw = false
1005 return r
1006 } finally {
1007 try {
1008 if (r && typeof r === 'object' && (r as { deny?: unknown }).deny) {
1009 await ungate($, key, o)
1010 await dropPending($, str(o, 'tool_use_id'))
1011 }
1012 // A spawn that throws still draws, as failed
1013 else await recordSpawn($, input, r, threw, key)
1014 } catch {
1015 // The spawn stands whatever the map does
1016 } finally {
1017 inflight.delete(key)
1018 }
1019 }
1020 })
1021
1022 // A permission dialog is on screen: the matching pending ask shows ⏸ waiting at once
1023 on('classic.PermissionRequest', async ($, e, next) => {
1024 try {
1025 dialogSignal = true
1026 const loop = str(e, 'agent_id', 'agentId') ?? MAIN
1027 const tool = str(e, 'tool_name', 'tool')
1028 for (const [k, p] of pendingAsks) {
1029 if (p.loop === loop && (!tool || p.tool === tool)) {
1030 await markWaiting($, k)
1031 break
1032 }
1033 }
1034 } catch {
1035 // Observing only
1036 }
1037 return next(e)
1038 })
1039
1040 // The settings PreToolUse point: a call that reaches it is going to run, so a
1041 // pending ask for it is dropped and a shown ⏸ waiting clears
1042 on('classic.PreToolUse', async ($, e, next) => {
1043 try {
1044 await clearApprovedWait($, e)
1045 } catch {
1046 // Observing only
1047 }
1048 return next(e)
1049 })
1050
1051 // A new main-loop turn: the per-turn count, the warning and the override start over
1052 on('turn.start', async ($, e, next) => {
1053 try {
1054 if (!str(e, 'agentId')) await update($, countersAtom, (c: CapCounters) => newTurn(c))
1055 } catch {
1056 // Best effort
1057 }
1058 return next(e)
1059 })
1060
1061 on('turn.step', async function* ($, e, next) {
1062 const result = yield* next(e)
1063 try {
1064 const id = str(e, 'agentId') ?? MAIN
1065 const usage = result && typeof result === 'object' ? (result as { usage?: unknown }).usage : undefined
1066 const known = id === MAIN || (await read($, agentsAtom))[id]?.isSpawned === true
1067 // e.effort is the request's effort as this hook received it (a mod above may have rewritten it)
1068 const step = { effort: effortOf((e as unknown as { effort?: unknown }).effort), prices: await read($, pricesAtom) }
1069 await changeAgent($, id, (a, now) => applyStep(resume(a, now), usage, str(e, 'model'), now, step))
1070 if (id !== MAIN) {
1071 // A loop no spawn announced: the engine's list may name it (a fork, a teammate)
1072 if (!known) await reconcileList($)
1073 await syncTicker($)
1074 }
1075 } catch {
1076 // Counting is best effort
1077 }
1078 return result
1079 })
1080
1081 on('turn.complete', async ($, e, next) => {
1082 try {
1083 await recordComplete($, e, false)
1084 } catch {
1085 // Best effort
1086 }
1087 return next(e)
1088 }).catch(async ($, e, next) => {
1089 try {
1090 await recordComplete($, e, true)
1091 } catch {
1092 // Best effort
1093 }
1094 return next.called ? { text: '' } : next(e)
1095 })
1096
1097 on('session.send', async ($, e, next) => {
1098 try {
1099 const o = e as unknown as Record<string, unknown>
1100 await recordMessage($, str(o, 'agentId', 'from') ?? MAIN, o.to)
1101 } catch {
1102 // Best effort
1103 }
1104 return next(e)
1105 })
1106
1107 on('session.receive', async ($, e, next) => {
1108 try {
1109 const o = e as unknown as Record<string, unknown>
1110 const origin = (o.origin && typeof o.origin === 'object' ? o.origin : {}) as Record<string, unknown>
1111 if (origin.kind === 'task-notification') {
1112 // A background agent's result reaches main as a task notification
1113 const task = (o.task && typeof o.task === 'object' ? o.task : origin.task && typeof origin.task === 'object' ? origin.task : {}) as Record<string, unknown>
1114 const agents = await read($, agentsAtom)
1115 const viaCall = str(task, 'toolUseId') ?? str(origin, 'toolUseId', 'tool_use_id')
1116 const id = resolveAddress(agents, str(task, 'id') ?? str(origin, 'agentId', 'taskId', 'id')) ?? (viaCall ? agentOfCall.get(viaCall) : undefined)
1117 const tokens = estimateTokens(typeof o.text === 'string' ? o.text : '')
1118 if (id && id !== MAIN && tokens > 0) {
1119 await changeAgent($, id, a => addReturned(a, tokens))
1120 await logEvent($, id, 'returned', fmtTokens(tokens) + ' tok to main (task notification)')
1121 await addToMain($, tokens)
1122 }
1123 } else {
1124 const from = str(o, 'from', 'agentId', 'sender') ?? str(origin, 'agentId', 'from', 'name')
1125 if (from) await recordMessage($, from, str(o, 'to') ?? MAIN)
1126 }
1127 } catch {
1128 // Best effort
1129 }
1130 return next(e)
1131 })
1132
1133 on('command.run', { command: 'agent-map' }, async ($, e) => {
1134 const args = (str(e, 'args') ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean)
1135 const verb = args[0] ?? ''
1136 await reconcileList($).catch(() => undefined)
1137 if (verb === '' || verb === 'open') {
1138 try {
1139 await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true })
1140 } catch {
1141 return { text: await mapText($) }
1142 }
1143 return {}
1144 }
1145 if (verb === 'clear') {
1146 const n = await clearFinishedAgents($)
1147 return { text: 'Cleared ' + n + ' finished agent' + (n === 1 ? '' : 's') }
1148 }
1149 if (verb === 'band') {
1150 const want = args[1]
1151 if (want !== 'on' && want !== 'off') return { text: USAGE }
1152 await setBand($, want === 'on')
1153 return { text: 'Agent band ' + want }
1154 }
1155 // Text prints the chosen view; with none chosen, the tree (the pane opens on the cards)
1156 if (verb === 'text') return { text: await viewText($, (await read($, viewAtom)) ?? 'tree') }
1157 const chosen = verb === 'view' ? (args[1] ?? '') : verb
1158 if (chosen === 'tree' || chosen === 'cards' || chosen === 'team') {
1159 const verb = chosen
1160 await setView($, verb)
1161 try {
1162 await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true })
1163 } catch {
1164 return { text: await viewText($, verb) }
1165 }
1166 return { text: 'Agent map: ' + verb + ' view' }
1167 }
1168 if (verb === 'log') return { text: logText(await read($, logAtom)) }
1169 if (verb === 'cap') return { text: await setCap($, args.slice(1)) }
1170 return { text: USAGE }
1171 })
1172
1173 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1174 const props = (e.props ?? {}) as { hasSurvey?: boolean; bodyColumns?: number }
1175 if (props.hasSurvey) return next(e)
1176 if (!(await read($, everAtom)) || !(await read($, bandAtom))) return next(e)
1177 const counters = await read($, countersAtom)
1178 const band = bandOf(await read($, agentsAtom), { caps: await read($, capsAtom), counters })
1179 const { Box, Text } = $.ui.resolve(e)
1180 const k = makeKit($.ui.resolve(e) as unknown as Elements, e.surface)
1181 // One kit line: `agents ● 3/4 running ✓ 5 done ✗ 1 ● ok` with the tokens on the right
1182 const chips = band.chips.map(c => k.text(c.text, { color: TONES[c.tone].fg, bold: c.tone !== 'muted', wrap: 'truncate' }))
1183 const mine = Box({
1184 key: 'agent-map-band',
1185 flexDirection: 'column',
1186 children: [k.line('agents', chips, band.right) as ReturnType<typeof Text>],
1187 })
1188 const theirs = await next(e)
1189 if (theirs && typeof theirs === 'object') return Box({ flexDirection: 'column', children: [mine, theirs] })
1190 return mine
1191 })
1192
1193 on('ui.render', { component: 'Pane', requestId: 'agent-map' }, async ($, e, next) => {
1194 if (e.requestId !== PANE) return next(e)
1195 const ui = $.ui.resolve(e)
1196 const { Box, Text, Button } = ui
1197 const k = makeKit(ui as unknown as Elements, e.surface)
1198 const props = (e.props ?? {}) as { bodyColumns?: number }
1199 const columns = Math.max(20, props.bodyColumns ?? 80)
1200 const agents = await read($, agentsAtom)hooks/model.ts 825 lines1// Pure data functions for agent-map. Nothing here touches `$`: register.ts
2// reads and writes $.state and hands these functions plain values.
3import type { AgentRecord, AgentStatus, CapConfig, CapCounters, CapKind, CapSource, CapSources, LogEntry, PendingCall, PriceRow, TodoProgress, TokenCounts, WaitingCall } from '../types'
4import { costUsd } from './prices'
5
6export const MAIN = 'main'
7export const MAX_PENDING = 32
8export const MAX_MESSAGES = 20
9export const ACTIVITY_INPUT = 40
10export const MAX_WAITING = 8
11
12export type Agents = Record<string, AgentRecord>
13
14/** Reads a string field from an event, trying each name in turn. */
15export function str(e: unknown, ...names: string[]): string | undefined {
16 if (!e || typeof e !== 'object') return undefined
17 const o = e as Record<string, unknown>
18 for (const n of names) {
19 const v = o[n]
20 if (typeof v === 'string' && v.length > 0) return v
21 }
22 return undefined
23}
24
25export function num(v: unknown): number {
26 return typeof v === 'number' && Number.isFinite(v) ? v : 0
27}
28
29export function zeroTokens(): TokenCounts {
30 return { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 }
31}
32
33export function totalTokens(t: TokenCounts | undefined): number {
34 if (!t) return 0
35 return num(t.input) + num(t.output) + num(t.cacheRead) + num(t.cacheCreate)
36}
37
38/** Token counts from an API usage object (turn.step result.usage, turn.complete e.usage). */
39export function tokensOf(usage: unknown): TokenCounts {
40 const u = (usage && typeof usage === 'object' ? usage : {}) as Record<string, unknown>
41 return {
42 input: num(u.input_tokens),
43 output: num(u.output_tokens),
44 cacheRead: num(u.cache_read_input_tokens),
45 cacheCreate: num(u.cache_creation_input_tokens),
46 }
47}
48
49export function addTokens(a: TokenCounts, b: TokenCounts): TokenCounts {
50 return {
51 input: num(a.input) + b.input,
52 output: num(a.output) + b.output,
53 cacheRead: num(a.cacheRead) + b.cacheRead,
54 cacheCreate: num(a.cacheCreate) + b.cacheCreate,
55 }
56}
57
58export function blankAgent(id: string, now: number): AgentRecord {
59 const isMain = id === MAIN
60 return {
61 id,
62 name: isMain ? 'orchestrator' : 'agent',
63 description: isMain ? 'main' : '',
64 prompt: '',
65 model: '',
66 parent: isMain ? '' : MAIN,
67 isTeammate: false,
68 isFork: false,
69 isBackground: false,
70 isSpawned: isMain,
71 startedAt: now,
72 runStartedAt: now,
73 status: 'running',
74 requests: 0,
75 toolCalls: 0,
76 toolsByName: {},
77 lastTool: '',
78 activity: '',
79 lastActiveAt: now,
80 waiting: [],
81 stuckNotified: false,
82 muted: false,
83 tokens: zeroTokens(),
84 messages: [],
85 effort: '',
86 lastRequestTokens: 0,
87 todo: null,
88 costUsd: null,
89 }
90}
91
92/** Returns a copy of `agents` with `id` changed by `fn`, creating the record when missing. */
93export function withAgent(agents: Agents, id: string, now: number, fn: (a: AgentRecord) => AgentRecord): Agents {
94 const prev = agents[id] ?? blankAgent(id, now)
95 return { ...agents, [id]: fn(prev) }
96}
97
98export function withOrder(order: string[], id: string): string[] {
99 return order.includes(id) ? order : [...order, id]
100}
101
102// ---- Agent tool calls and spawns ----
103
104/** What a model's Agent tool.call says about the agent about to start. */
105export function pendingOf(e: unknown, now: number): PendingCall {
106 const o = (e && typeof e === 'object' ? e : {}) as Record<string, unknown>
107 return {
108 toolUseId: str(o, 'tool_use_id', 'toolUseId') ?? '',
109 caller: str(o, 'agentId') ?? MAIN,
110 description: str(o, 'description') ?? '',
111 subagentType: str(o, 'subagent_type', 'subagentType') ?? '',
112 model: str(o, 'model') ?? '',
113 prompt: str(o, 'prompt') ?? '',
114 isBackground: o.run_in_background === true,
115 at: now,
116 }
117}
118
119export function addPending(list: PendingCall[], p: PendingCall): PendingCall[] {
120 return [...list, p].slice(-MAX_PENDING)
121}
122
123/**
124 * Finds the Agent call a spawn belongs to: by tool_use_id, then by
125 * description, then the oldest still waiting. Returns it and the rest.
126 */
127export function takePending(list: PendingCall[], spawn: unknown): { match?: PendingCall; rest: PendingCall[] } {
128 const toolUseId = str(spawn, 'tool_use_id', 'toolUseId')
129 const description = str(spawn, 'description')
130 let i = toolUseId ? list.findIndex(p => p.toolUseId === toolUseId) : -1
131 if (i < 0 && description) i = list.findIndex(p => p.description === description)
132 if (i < 0 && !toolUseId && list.length > 0) i = 0
133 if (i < 0) return { rest: list }
134 return { match: list[i], rest: list.filter((_, j) => j !== i) }
135}
136
137export type SpawnFacts = {
138 id: string
139 parent: string
140 name: string
141 description: string
142 prompt: string
143 model: string
144 isTeammate: boolean
145 isFork: boolean
146 isBackground: boolean
147 /** The spawn's effort, when the event carries one (not a documented field: read defensively). */
148 effort: string
149}
150
151/**
152 * Merges what agent.spawn's input (e), its result (r) and the matching Agent
153 * tool.call (p) say. The caller of the Agent tool is the parent.
154 */
155export function spawnFacts(e: unknown, r: unknown, p: PendingCall | undefined): SpawnFacts {
156 const o = (e && typeof e === 'object' ? e : {}) as Record<string, unknown>
157 const toolUseId = str(o, 'tool_use_id', 'toolUseId')
158 const id =
159 str(r, 'agentId') ??
160 str(o, 'agentId', 'id') ??
161 (toolUseId ? 'tool:' + toolUseId : 'agent:' + (str(o, 'description') ?? 'unknown'))
162 return {
163 id,
164 parent: str(o, 'parentAgentId') ?? p?.caller ?? MAIN,
165 name:
166 str(o, 'subagentType', 'subagent_type', 'agentType', 'type') ??
167 (p?.subagentType || undefined) ??
168 (o.isTeammate === true ? 'teammate' : 'general-purpose'),
169 description: str(o, 'description', 'name') ?? p?.description ?? '',
170 prompt: str(o, 'prompt') ?? p?.prompt ?? '',
171 model: str(r, 'model') ?? str(o, 'model') ?? (p?.model || undefined) ?? str(o, 'parentModel') ?? '',
172 isTeammate: o.isTeammate === true,
173 isFork: o.fork === true,
174 isBackground: o.background === true || o.run_in_background === true || p?.isBackground === true,
175 effort: effortOf(o.effort),
176 }
177}
178
179export function applySpawn(a: AgentRecord, f: SpawnFacts, now: number): AgentRecord {
180 return {
181 ...a,
182 name: f.name || a.name,
183 description: f.description || a.description,
184 prompt: f.prompt || a.prompt,
185 model: f.model || a.model,
186 parent: f.parent === f.id ? MAIN : f.parent,
187 isTeammate: f.isTeammate || a.isTeammate,
188 isFork: f.isFork || a.isFork,
189 isBackground: f.isBackground,
190 // A request's effort (turn.step) stands over the spawn's
191 effort: a.effort || f.effort || '',
192 isSpawned: true,
193 // Keep the earlier start when a tool call or step arrived before the spawn resolved
194 startedAt: a.isSpawned || a.requests > 0 || a.toolCalls > 0 ? a.startedAt : now,
195 runStartedAt: a.isSpawned || a.requests > 0 || a.toolCalls > 0 ? a.runStartedAt : now,
196 status: 'running',
197 endedAt: undefined,
198 lastActiveAt: now,
199 }
200}
201
202// ---- Activity inside a loop ----
203
204const INPUT_KEYS = ['command', 'file_path', 'pattern', 'description', 'url', 'query', 'path', 'skill', 'task_id', 'prompt']
205
206const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
207
208/** `Bash: npm test`: the tool and the first 40 characters of the input that says most about the call. */
209export function activityOf(tool: string, e: unknown): string {
210 const name = tool || 'tool'
211 const o = (e && typeof e === 'object' ? e : {}) as Record<string, unknown>
212 const input = (o.input && typeof o.input === 'object' ? o.input : o) as Record<string, unknown>
213 const raw = str(input, ...INPUT_KEYS)
214 const text = raw ? Array.from(oneLine(raw)).slice(0, ACTIVITY_INPUT).join('') : ''
215 return text ? name + ': ' + text : name
216}
217
218export function applyToolCall(a: AgentRecord, tool: string, activity?: string, now?: number): AgentRecord {
219 const name = tool || 'tool'
220 return {
221 ...a,
222 toolCalls: a.toolCalls + 1,
223 toolsByName: { ...a.toolsByName, [name]: (a.toolsByName[name] ?? 0) + 1 },
224 lastTool: name,
225 activity: activity ?? name,
226 lastActiveAt: now ?? a.lastActiveAt,
227 stuckNotified: false,
228 }
229}
230
231/** The last thing an agent's own transcript shows, for an agent no tool.call was seen from. */
232export function activityOfMessages(messages: unknown): string {
233 if (!Array.isArray(messages)) return ''
234 for (let i = messages.length - 1; i >= 0; i--) {
235 const m = messages[i] as { role?: unknown; text?: unknown; toolUses?: unknown }
236 const uses = Array.isArray(m?.toolUses) ? (m.toolUses as { tool?: unknown; input?: unknown }[]) : []
237 const last = uses[uses.length - 1]
238 if (last && typeof last.tool === 'string') return activityOf(last.tool, { input: last.input })
239 if (m?.role === 'assistant' && typeof m.text === 'string' && m.text.trim())
240 return 'said: ' + Array.from(oneLine(m.text)).slice(0, ACTIVITY_INPUT).join('')
241 }
242 return ''
243}
244
245// ---- Permission verdicts ----
246
247export function addWaiting(a: AgentRecord, w: WaitingCall): AgentRecord {
248 if (a.waiting.some(x => x.toolUseId === w.toolUseId)) return a
249 return { ...a, waiting: [...a.waiting, w].slice(-MAX_WAITING) }
250}
251
252export function clearWaiting(agents: Agents, toolUseId: string): Agents {
253 let out = agents
254 for (const a of Object.values(agents)) {
255 if (!a.waiting?.some(w => w.toolUseId === toolUseId)) continue
256 out = { ...out, [a.id]: { ...a, waiting: a.waiting.filter(w => w.toolUseId !== toolUseId) } }
257 }
258 return out
259}
260
261// ---- Stop and mute ----
262
263export function markStopped(a: AgentRecord, by: string, now: number): AgentRecord {
264 if (a.status === 'stopped') return a
265 const ran = a.status === 'running'
266 return {
267 ...a,
268 status: 'stopped',
269 stoppedBy: by,
270 waiting: [],
271 endedAt: ran ? now : (a.endedAt ?? now),
272 durationMs: ran ? (a.durationMs ?? 0) + Math.max(0, now - a.runStartedAt) : a.durationMs,
273 }
274}
275
276export function markMuted(a: AgentRecord): AgentRecord {
277 return { ...a, muted: true }
278}
279
280export const MUTE_REASON =
281 'The person muted this agent from the agent map. Make no more tool calls: return your result now with what you have.'
282
283// ---- Idle alert ----
284
285/** How long a running agent has gone without a tool call or a model request. */
286export function idleMs(a: AgentRecord, now: number): number {
287 if (a.status !== 'running' || a.id === MAIN) return 0
288 return Math.max(0, now - (a.lastActiveAt ?? a.runStartedAt))
289}
290
291export function isStuck(a: AgentRecord, now: number, stuckAfterMs: number): boolean {
292 return stuckAfterMs > 0 && a.isSpawned && (a.waiting?.length ?? 0) === 0 && !a.muted && idleMs(a, now) >= stuckAfterMs
293}
294
295// ---- Reconcile with $.agent.list() ----
296
297export type ListedAgent = { id: string; description?: string; type?: string; status?: string; parentId?: string; name?: string }
298
299export function statusOfListed(s: string | undefined): AgentStatus {
300 if (s === 'running' || s === 'pending' || !s) return 'running'
301 if (s === 'completed' || s === 'done') return 'done'
302 if (s === 'killed' || s === 'stopped') return 'stopped'
303 if (s === 'failed' || s === 'error') return 'failed'
304 return 'aborted'
305}
306
307/** True when making `parent` the parent of `id` closes a loop. */
308export function makesCycle(agents: Agents, id: string, parent: string): boolean {
309 const seen = new Set<string>()
310 let p: string | undefined = parent
311 while (p && p !== MAIN) {
312 if (p === id || seen.has(p)) return true
313 seen.add(p)
314 p = agents[p]?.parent
315 }
316 return false
317}
318
319/**
320 * Merges the engine's own list of agents into the map: agents the spawn hook
321 * missed (forks, teammates, workflow agents, ones that started before a
322 * reload) appear, parents fill in, and finished ones end. A parent that would
323 * close a loop is left at main.
324 */
325export function reconcile(agents: Agents, order: string[], listed: readonly ListedAgent[], now: number): { agents: Agents; order: string[]; added: string[] } {
326 let out = agents
327 let nextOrder = order
328 const added: string[] = []
329 for (const l of listed) {
330 if (!l || typeof l.id !== 'string' || !l.id || l.id === MAIN) continue
331 const status = statusOfListed(l.status)
332 const prev = out[l.id]
333 const parentId = l.parentId && l.parentId !== l.id ? l.parentId : MAIN
334 if (!prev || !prev.isSpawned) {
335 const base = prev ?? blankAgent(l.id, now)
336 const parent = makesCycle(out, l.id, parentId) ? MAIN : parentId
337 let rec: AgentRecord = {
338 ...base,
339 name: l.type || base.name,
340 description: l.description || l.name || base.description,
341 parent,
342 isTeammate: l.type === 'teammate' || base.isTeammate,
343 isSpawned: true,
344 }
345 if (status !== 'running') rec = status === 'stopped' ? markStopped(rec, 'engine', now) : { ...rec, status, endedAt: now, durationMs: Math.max(0, now - rec.runStartedAt) }
346 out = { ...out, [l.id]: rec }
347 nextOrder = withOrder(nextOrder, l.id)
348 added.push(l.id)
349 continue
350 }
351 let rec = prev
352 if ((rec.parent === MAIN || !rec.parent) && parentId !== MAIN && !makesCycle(out, l.id, parentId)) rec = { ...rec, parent: parentId }
353 if (rec.status === 'running' && status !== 'running') {
354 rec = status === 'stopped' ? markStopped(rec, rec.stoppedBy ?? 'engine', now) : { ...rec, status, endedAt: now, waiting: [], durationMs: (rec.durationMs ?? 0) + Math.max(0, now - rec.runStartedAt) }
355 }
356 if (rec !== prev) out = { ...out, [l.id]: rec }
357 }
358 return { agents: out, order: nextOrder, added }
359}
360
361/** A finished agent that makes a request again (a resumed run) is running again. */
362export function resume(a: AgentRecord, now: number): AgentRecord {
363 if (a.status === 'running' || a.id === MAIN) return a
364 return { ...a, status: 'running', endedAt: undefined, runStartedAt: now }
365}
366
367export type StepOptions = {
368 /** turn.step's e.effort, as effortOf() reads it. */
369 effort?: string
370 /** The price table the request's cost is figured with (hooks/prices.ts PRICES and the config's overrides). */
371 prices?: readonly PriceRow[]
372}
373
374/**
375 * One model request: tokens and cost add up, the model it actually ran on replaces the spawn's,
376 * its effort replaces the spawn's, and its input side (input + cache read + cache write) is
377 * kept as the agent's latest context size.
378 */
379export function applyStep(a: AgentRecord, usage: unknown, model: string | undefined, now?: number, opts: StepOptions = {}): AgentRecord {
380 const u = usage && typeof usage === 'object' ? (usage as Record<string, unknown>) : undefined
381 const answered = u && typeof u.model === 'string' && u.model ? (u.model as string) : undefined
382 const ranOn = answered ?? (model || a.model || '')
383 const t = u ? tokensOf(u) : undefined
384 const cost = t ? costUsd(t, ranOn, opts.prices) : null
385 return {
386 ...a,
387 requests: a.requests + 1,
388 tokens: t ? addTokens(a.tokens, t) : a.tokens,
389 model: ranOn,
390 lastActiveAt: now ?? a.lastActiveAt,
391 stuckNotified: false,
392 effort: opts.effort || a.effort || '',
393 lastRequestTokens: t ? t.input + t.cacheRead + t.cacheCreate : (a.lastRequestTokens ?? 0),
394 costUsd: cost === null ? (a.costUsd ?? null) : (a.costUsd ?? 0) + cost,
395 }
396}
397
398/** An effort as the card shows it: a level (`low` … `max`) as given, an integer budget as digits, else ''. */
399export function effortOf(v: unknown): string {
400 if (typeof v === 'number' && Number.isFinite(v) && v > 0) return String(Math.round(v))
401 if (typeof v === 'string') return Array.from(oneLine(v)).slice(0, 12).join('')
402 return ''
403}
404
405/**
406 * The progress a TodoWrite call's list shows: completed items over all items, and the
407 * in_progress item's activeForm (else its content). Reads `todos` on the event or its `input`.
408 * undefined when the call carries no list; null for an empty list.
409 */
410export function todoOf(e: unknown): TodoProgress | null | undefined {
411 const o = (e && typeof e === 'object' ? e : {}) as Record<string, unknown>
412 const input = (o.input && typeof o.input === 'object' ? o.input : {}) as Record<string, unknown>
413 const list = Array.isArray(o.todos) ? o.todos : Array.isArray(input.todos) ? input.todos : undefined
414 if (!list) return undefined
415 const items = list.filter(x => x && typeof x === 'object') as Record<string, unknown>[]
416 if (items.length === 0) return null
417 let done = 0
418 let current = ''
419 for (const it of items) {
420 if (it.status === 'completed') done++
421 else if (it.status === 'in_progress' && !current) current = oneLine(str(it, 'activeForm') ?? str(it, 'content') ?? '')
422 }
423 return { done, total: items.length, current: Array.from(current).slice(0, 80).join('') }
424}
425
426export function statusOfComplete(e: unknown): AgentStatus {
427 const o = (e && typeof e === 'object' ? e : {}) as Record<string, unknown>
428 if (o.isAborted === true || o.reason === 'aborted') return 'aborted'
429 if (o.reason === 'error' || o.reason === 'refusal') return 'failed'
430 return 'done'
431}
432
433export function applyComplete(a: AgentRecord, e: unknown, now: number, status: AgentStatus, prices?: readonly PriceRow[]): AgentRecord {
434 const o = (e && typeof e === 'object' ? e : {}) as Record<string, unknown>
435 const runMs = typeof o.durationMs === 'number' && o.durationMs >= 0 ? o.durationMs : Math.max(0, now - a.runStartedAt)
436 // turn.step already summed this run's tokens; use the turn's totals only when no step was seen
437 const fromTurn = a.requests === 0 && o.usage ? tokensOf(o.usage) : undefined
438 const tokens = fromTurn ? addTokens(a.tokens, fromTurn) : a.tokens
439 const model = a.model || str(o.usage, 'model') || ''
440 const turnCost = fromTurn ? costUsd(fromTurn, model, prices) : null
441 if (turnCost !== null) a = { ...a, costUsd: (a.costUsd ?? 0) + turnCost }
442 if (a.status !== 'running') {
443 // A second turn.complete for a finished run (or a .catch after a recorded end): keep the totals.
444 // A stop recorded from the pane stands over the abort the engine reports for it.
445 const keep = status === 'done' || a.status === 'stopped'
446 return { ...a, status: keep ? a.status : status, tokens, model }
447 }
448 return {
449 ...a,
450 waiting: [],
451 status,
452 endedAt: now,
453 durationMs: (a.durationMs ?? 0) + runMs,
454 tokens,
455 model,
456 }
457}
458
459export function markFailed(a: AgentRecord, now: number): AgentRecord {
460 if (a.status === 'stopped') return a
461 return {
462 ...a,
463 waiting: [],
464 status: 'failed',
465 endedAt: a.endedAt ?? now,
466 durationMs: a.durationMs ?? Math.max(0, now - a.runStartedAt),
467 }
468}
469
470// ---- Messages ----
471
472/** Resolves a message address (an agent id, an Agent({ name }), or { agentId }) to a known agent id. */
473export function resolveAddress(agents: Agents, to: unknown): string | undefined {
474 let key: string | undefined
475 if (typeof to === 'string') key = to
476 else if (to && typeof to === 'object') key = str(to, 'agentId', 'id', 'name', 'to')
477 if (!key) return undefined
478 if (key === MAIN) return MAIN
479 if (agents[key]) return key
480 const bare = key.replace(/^agent:/, '')
481 if (agents[bare]) return bare
482 const byName = Object.values(agents).find(a => a.isSpawned && (a.description === key || a.name === key))
483 return byName?.id
484}
485
486export function addEdge(agents: Agents, from: string, to: string, now: number): Agents {
487 let out = agents
488 if (out[from]) out = { ...out, [from]: { ...out[from], messages: [...out[from].messages, { to, at: now }].slice(-MAX_MESSAGES) } }
489 if (out[to]) out = { ...out, [to]: { ...out[to], messages: [...out[to].messages, { from, at: now }].slice(-MAX_MESSAGES) } }
490 return out
491}
492
493// ---- Housekeeping ----
494
495export function isFinished(a: AgentRecord): boolean {
496 return a.id !== MAIN && a.status !== 'running'
497}
498
499/** Drops finished agents; running ones stay, and an orphan draws at the top level. */
500export function clearFinished(agents: Agents, order: string[]): { agents: Agents; order: string[]; removed: number } {
501 const keep: Agents = {}
502 let removed = 0
503 for (const [id, a] of Object.entries(agents)) {
504 if (a.isSpawned && isFinished(a)) removed++
505 else keep[id] = a
506 }
507 return { agents: keep, order: order.filter(id => keep[id] !== undefined), removed }
508}
509
510export type Counts = { running: number; done: number; failed: number; aborted: number; stopped: number; tokens: number; spawned: number }
511
512export function countsOf(agents: Agents): Counts {
513 const c: Counts = { running: 0, done: 0, failed: 0, aborted: 0, stopped: 0, tokens: 0, spawned: 0 }
514 for (const a of Object.values(agents)) {
515 if (a.id === MAIN || !a.isSpawned) continue
516 c.spawned++
517 if (a.status in c) c[a.status]++
518 c.tokens += totalTokens(a.tokens)
519 }
520 return c
521}
522
523export function anyRunning(agents: Agents): boolean {
524 return Object.values(agents).some(a => a.id !== MAIN && a.isSpawned && a.status === 'running')
525}
526
527
528// ---- Spawn caps (0.3.0) ----
529
530export const DEFAULT_CAPS: CapConfig = {
531 maxConcurrent: 4,
532 maxPerTurn: 8,
533 maxPerSession: 40,
534 warnAt: 0.75,
535 maxReturnedTokens: 150_000,
536 waitGraceMs: 4000,
537}
538
539export function zeroCounters(): CapCounters {
540 return { turn: 0, session: 0, deniedThisTurn: false, warnedThisTurn: false, overrideTurn: false, returnedTotal: 0, returnedWarned: false }
541}
542
543/** The counters a new main-loop turn starts with: session totals stay. */
544export function newTurn(c: CapCounters): CapCounters {
545 return { ...c, turn: 0, deniedThisTurn: false, warnedThisTurn: false, overrideTurn: false }
546}
547
548const CAP_FIELDS = ['maxConcurrent', 'maxPerTurn', 'maxPerSession', 'maxReturnedTokens', 'waitGraceMs'] as const
549
550/** The valid cap fields of `over` (a parsed config file, the plugin's settings or the store's overrides). */
551export function capFields(over: unknown): Partial<CapConfig> {
552 if (!over || typeof over !== 'object') return {}
553 const o = over as Record<string, unknown>
554 const out: Partial<CapConfig> = {}
555 for (const k of CAP_FIELDS) {
556 const v = o[k]
557 if (typeof v === 'number' && Number.isFinite(v) && v >= 0) out[k] = Math.floor(v)
558 }
559 const w = o.warnAt
560 if (typeof w === 'number' && Number.isFinite(w) && w > 0 && w <= 1) out.warnAt = w
561 return out
562}
563
564/** Lays the valid fields of `over` (a parsed config file or the store's overrides) on top of `base`. */
565export function mergeCaps(base: CapConfig, over: unknown): CapConfig {
566 if (!over || typeof over !== 'object') return base
567 return { ...base, ...capFields(over) }
568}
569
570/** Every cap from the defaults. */
571export const DEFAULT_SOURCES: CapSources = {
572 maxConcurrent: 'default',
573 maxPerTurn: 'default',
574 maxPerSession: 'default',
575 warnAt: 'default',
576 maxReturnedTokens: 'default',
577 waitGraceMs: 'default',
578}
579
580/** The caps after each layer in turn (later wins), and the layer each value came from. */
581export function layerCaps(layers: readonly { source: CapSource; over: unknown }[]): { caps: CapConfig; sources: CapSources } {
582 let caps: CapConfig = { ...DEFAULT_CAPS }
583 const sources: CapSources = { ...DEFAULT_SOURCES }
584 for (const l of layers) {
585 const f = capFields(l.over)
586 caps = { ...caps, ...f }
587 for (const k of Object.keys(f) as (keyof CapConfig)[]) sources[k] = l.source
588 }
589 return { caps, sources }
590}
591
592// ---- The caps in the plugin's settings (0.6.2) ----
593
594/** The plugin's `userConfig` fields for the caps; warnAtPercent is warnAt × 100. */
595export const SETTING_FIELDS = ['maxConcurrent', 'maxPerTurn', 'maxPerSession', 'warnAtPercent', 'maxReturnedTokens'] as const
596export type SettingField = (typeof SETTING_FIELDS)[number]
597
598/** A `/config` row of this plugin's cap settings: `lk-agent-map.<field>`, or `lk-agent-map@<marketplace>.<field>`. */
599export const SETTING_KEY = /^lk-agent-map(@[^.]*)?\.(maxConcurrent|maxPerTurn|maxPerSession|warnAtPercent|maxReturnedTokens)$/
600
601/** Why a value doesn't fit a cap setting, or undefined when it does. */
602export function settingProblem(field: string, value: unknown): string | undefined {
603 if (field === 'warnAtPercent') {
604 return typeof value === 'number' && Number.isFinite(value) && value >= 1 && value <= 100 ? undefined : 'agent-map: warnAtPercent takes a number from 1 to 100'
605 }
606 if (!(SETTING_FIELDS as readonly string[]).includes(field)) return undefined
607 return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? undefined : 'agent-map: ' + field + ' takes a whole number, 0 or more (0 means no cap)'
608}
609
610/**
611 * The settings' values by field: this plugin's `/config` rows when listed, else the values
612 * `register(on, options)` got.
613 */
614export function settingValues(rows: unknown, options: unknown): Record<string, unknown> {
615 const out: Record<string, unknown> = {}
616 if (options && typeof options === 'object') {
617 const o = options as Record<string, unknown>
618 for (const f of SETTING_FIELDS) if (f in o) out[f] = o[f]
619 }
620 if (Array.isArray(rows)) {
621 for (const r of rows) {
622 const m = r && typeof r === 'object' ? SETTING_KEY.exec(String((r as { key?: unknown }).key)) : null
623 if (m) out[m[2]] = (r as { value?: unknown }).value
624 }
625 }
626 return out
627}
628
629/** The caps the settings set: valid values that differ from the defaults (options arrive with the defaults filled in). */
630export function settingsCaps(values: Record<string, unknown>): Partial<CapConfig> {
631 const out: Partial<CapConfig> = {}
632 for (const f of SETTING_FIELDS) {
633 const v = values[f]
634 if (v === undefined || settingProblem(f, v) !== undefined) continue
635 const key: keyof CapConfig = f === 'warnAtPercent' ? 'warnAt' : f
636 const n = f === 'warnAtPercent' ? (v as number) / 100 : (v as number)
637 if (n !== DEFAULT_CAPS[key]) out[key] = n
638 }
639 return out
640}
641
642/** Parses a config file's text; anything that isn't a JSON object is ignored. */
643export function parseCapFile(text: unknown): unknown {
644 if (typeof text !== 'string' || !text.trim()) return undefined
645 try {
646 const v = JSON.parse(text)
647 return v && typeof v === 'object' && !Array.isArray(v) ? v : undefined
648 } catch {
649 return undefined
650 }
651}
652
653export const CAP_NAMES: Record<CapKind, string> = {
654 concurrent: 'concurrency cap',
655 turn: 'per-turn spawn cap',
656 session: 'per-session spawn cap',
657}
658
659const CAP_MAX: Record<CapKind, keyof CapConfig> = { concurrent: 'maxConcurrent', turn: 'maxPerTurn', session: 'maxPerSession' }
660
661export type CapUse = { kind: CapKind; n: number; max: number }
662
663/** Running subagents (forks and teammates included). */
664export function runningCount(agents: Agents): number {
665 let n = 0
666 for (const a of Object.values(agents)) if (a.id !== MAIN && a.isSpawned && a.status === 'running') n++
667 return n
668}
669
670/** Where each cap stands before a spawn: running agents (plus spawns in flight), this turn's spawns, the session's. */
671export function capUses(running: number, c: CapCounters, caps: CapConfig): CapUse[] {
672 return [
673 { kind: 'concurrent', n: running, max: caps.maxConcurrent },
674 { kind: 'turn', n: c.turn, max: caps.maxPerTurn },
675 { kind: 'session', n: c.session, max: caps.maxPerSession },
676 ]
677}
678
679/** The first cap one more spawn would go past; a turn-exempt spawn (fork, teammate) skips the per-turn cap. */
680export function capBreach(uses: CapUse[], countsTurn = true): CapUse | undefined {
681 return uses.find(u => u.max > 0 && u.n >= u.max && (countsTurn || u.kind !== 'turn'))
682}
683
684/** The first cap at or past warnAt once this spawn is counted. */
685export function capNear(uses: CapUse[], warnAt: number, countsTurn = true): CapUse | undefined {
686 return uses
687 .filter(u => countsTurn || u.kind !== 'turn')
688 .map(u => ({ ...u, n: u.n + 1 }))
689 .find(u => u.max > 0 && u.n / u.max >= warnAt)
690}
691
692/** True while a cap stands at or past warnAt (for the band's color). */
693export function nearAnyCap(uses: CapUse[], warnAt: number): boolean {
694 return uses.some(u => u.max > 0 && u.n / u.max >= warnAt)
695}
696
697export function denyText(u: CapUse): string {
698 return (
699 'agent-map: ' + CAP_NAMES[u.kind] + ' reached (' + u.n + '/' + u.max + '). Wait for running subagents to return, or batch the work into fewer agents. ' +
700 'The user can raise it with /agent-map cap ' + u.kind + ' <n>.'
701 )
702}
703
704export function nearText(u: CapUse): string {
705 const what = u.kind === 'concurrent' ? 'running' : u.kind === 'turn' ? 'spawned this turn' : 'spawned this session'
706 return 'agents ' + u.n + '/' + u.max + ' ' + what + ', near the ' + CAP_NAMES[u.kind]
707}
708
709export const OVERRIDE_RE = /\[cap-override:\s*([^\]]*)\]/i
710
711/** The reason of a `[cap-override: <reason>]` marker in any of the texts, if there is one. */
712export function overrideReason(...texts: unknown[]): string | undefined {
713 for (const t of texts) {
714 if (typeof t !== 'string') continue
715 const m = OVERRIDE_RE.exec(t)
716 if (m) return m[1].replace(/\s+/g, ' ').trim() || 'no reason given'
717 }
718 return undefined
719}
720
721export function stripOverride(s: string): string {
722 return s.replace(new RegExp(OVERRIDE_RE.source, 'gi'), '').replace(/\s{2,}/g, ' ').trim()
723}
724
725export const ASK_ALLOW_ONE = 'Allow this one'
726export const ASK_ALLOW_TURN = 'Allow the rest of the turn'
727export const ASK_DENY = 'Deny'
728
729export function askText(reason: string): string {
730 return 'agent-map: ' + reason + '. Allow one more agent beyond the cap?'
731}
732
733export type CapCommandKind = CapKind | 'returned' | 'warn' | 'grace'
734export type CapCommand = { kind: CapCommandKind; value: number } | { error: string }
735
736const CAP_KEY: Record<string, keyof CapConfig> = {
737 concurrent: 'maxConcurrent',
738 turn: 'maxPerTurn',
739 session: 'maxPerSession',
740 returned: 'maxReturnedTokens',
741 warn: 'warnAt',
742 grace: 'waitGraceMs',
743}
744
745export const CAP_USAGE =
746 'Usage: /agent-map cap [concurrent <n> | turn <n> | session <n> | returned <k> | warn <0.1-1> | grace <ms> | reset [<kind>]] (0 turns a cap off)'
747
748const isCapWord = (kind: string | undefined): kind is string => !!kind && Object.prototype.hasOwnProperty.call(CAP_KEY, kind)
749
750/** `/agent-map cap concurrent 6`, `cap returned 200` (thousands of tokens) or `cap returned 200k`, `cap warn 0.8`. */
751export function parseCapCommand(args: readonly string[]): CapCommand {
752 const kind = args[0]
753 const raw = (args[1] ?? '').toLowerCase()
754 if (!isCapWord(kind) || !raw) return { error: CAP_USAGE }
755 const m = /^(\d+(?:\.\d+)?)(k)?$/.exec(raw)
756 if (!m) return { error: CAP_USAGE }
757 let value = Number(m[1])
758 if (kind === 'warn') {
759 if (!(value > 0 && value <= 1)) return { error: 'warn takes a share between 0.1 and 1, such as 0.75' }
760 return { kind: 'warn', value }
761 }
762 if (kind === 'returned') value = m[2] || value < 1000 ? value * 1000 : value
763 return { kind: kind as CapKind | 'returned' | 'grace', value: Math.floor(value) }
764}
765
766export function capKeyOf(kind: CapKind | 'returned' | 'warn' | 'grace'): keyof CapConfig {
767 return CAP_KEY[kind]
768}
769
770/** `/agent-map cap reset [kind]`: the saved override to clear, every one without a kind. */
771export function parseCapReset(args: readonly string[]): { kind?: CapCommandKind; key?: keyof CapConfig } | { error: string } {
772 const kind = args[1]
773 if (kind === undefined) return {}
774 if (!isCapWord(kind) || args.length > 2) return { error: CAP_USAGE }
775 return { kind: kind as CapCommandKind, key: CAP_KEY[kind] }
776}
777
778export function capMaxOf(caps: CapConfig, kind: CapKind): number {
779 return caps[CAP_MAX[kind]]
780}
781
782// ---- Context cost of results (0.3.0) ----
783
784/** Tokens a text costs, estimated as characters / 4. */
785export function estimateTokens(text: string): number {
786 return text ? Math.ceil(text.length / 4) : 0
787}
788
789/** The text of a tool result as the model reads it: core's `text`, else the result itself. */
790export function resultText(r: unknown): string {
791 if (!r || typeof r !== 'object') return typeof r === 'string' ? r : ''
792 const o = r as Record<string, unknown>
793 if (o.deny) return ''
794 if (typeof o.text === 'string') return o.text
795 const res = o.result
796 if (typeof res === 'string') return res
797 if (res === undefined || res === null) return ''
798 if (typeof res === 'object') {
799 const content = (res as Record<string, unknown>).content
800 if (Array.isArray(content)) {
801 const joined = content
802 .map(b => (b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string' ? (b as { text: string }).text : ''))
803 .join('')
804 if (joined) return joined
805 }
806 }
807 try {
808 return JSON.stringify(res)
809 } catch {
810 return ''
811 }
812}
813
814export function addReturned(a: AgentRecord, tokens: number): AgentRecord {
815 return { ...a, returnedTokens: (a.returnedTokens ?? 0) + tokens }
816}
817
818// ---- Lifecycle log (0.3.0) ----
819
820export const MAX_LOG = 200
821
822export function addLog(list: readonly LogEntry[], entry: LogEntry): LogEntry[] {
823 return [...list, entry].slice(-MAX_LOG)
824}
825hooks/format.ts 657 lines1// Pure text layout for agent-map: the tree rows the pane draws and the
2// /agent-map text command prints, the band's one line, and the cards' critter avatar.
3//
4// Colors are theme keys (suggestion, success, error, claude, permission, inactive) that the host
5// tunes for light and dark; Svg markup cannot read them, so the critter uses fixed hexes.
6import type { AgentRecord, AgentStatus, CapConfig, CapCounters, LogEntry } from '../types'
7import type { Tone } from './kit'
8import { DEFAULT_CAPS, MAIN, capUses, countsOf, idleMs, isStuck, nearAnyCap, runningCount, totalTokens, zeroCounters } from './model'
9import type { Agents, Counts } from './model'
10
11export const LABEL_MAX = 24
12export const PROMPT_EXCERPT = 200
13/** The width /agent-map text lays out to. */
14export const TEXT_COLUMNS = 120
15
16export type RowKind = 'header' | 'agent' | 'activity' | 'msg' | 'detail' | 'empty' | 'lane' | 'log'
17
18export type Row = {
19 kind: RowKind
20 /** The agent the row belongs to. */
21 id?: string
22 /** Tree drawing in front of the row: ├─, └─, │ and spaces. */
23 prefix: string
24 glyph?: string
25 /** A theme key for the glyph. */
26 color?: string
27 dim?: boolean
28 body: string
29 /** A status note after the body, such as `⏸ waiting: Bash` or `⚠ idle 5m`. */
30 flag?: string
31 flagColor?: string
32 /** The agent row of a running agent: the pane gives it a Stop button. */
33 canStop?: boolean
34}
35
36export const FORK_MARK = '⑂'
37export const TEAMMATE_MARK = '◇'
38export const DEFAULT_STUCK_MS = 300_000
39
40export function fmtTokens(n: number): string {
41 const v = Math.max(0, Math.round(n || 0))
42 if (v < 1000) return String(v)
43 const k = Math.round(v / 1000)
44 if (k < 1000) return k + 'k'
45 return (v / 1e6).toFixed(1) + 'M'
46}
47
48const pad2 = (n: number) => (n < 10 ? '0' : '') + n
49
50export function fmtElapsed(ms: number): string {
51 const s = Math.max(0, Math.floor((ms || 0) / 1000))
52 const h = Math.floor(s / 3600)
53 const m = Math.floor((s % 3600) / 60)
54 const sec = s % 60
55 return h > 0 ? h + ':' + pad2(m) + ':' + pad2(sec) : pad2(m) + ':' + pad2(sec)
56}
57
58export function shortModel(model: string): string {
59 const m = (model || '').toLowerCase()
60 for (const k of ['opus', 'sonnet', 'haiku', 'fable']) if (m.includes(k)) return k
61 if (!m) return '?'
62 return m.replace(/^claude-/, '').slice(0, 12)
63}
64
65export function elapsedOf(a: AgentRecord, now: number): number {
66 if (a.status === 'running') return (a.durationMs ?? 0) + Math.max(0, now - a.runStartedAt)
67 if (typeof a.durationMs === 'number') return a.durationMs
68 return Math.max(0, (a.endedAt ?? now) - a.startedAt)
69}
70
71export function glyphOf(status: AgentStatus): { glyph: string; color?: string; dim?: boolean } {
72 switch (status) {
73 case 'running':
74 return { glyph: '●', color: 'success' }
75 case 'done':
76 return { glyph: '✓', color: 'success' }
77 case 'failed':
78 return { glyph: '✗', color: 'error' }
79 case 'stopped':
80 return { glyph: '■', color: 'claude' }
81 default:
82 return { glyph: '○', color: 'inactive', dim: true }
83 }
84}
85
86const oneLine = (s: string) => (s || '').replace(/\s+/g, ' ').trim()
87
88export function labelOf(a: AgentRecord | undefined, fallback = ''): string {
89 if (!a) return fallback
90 return oneLine(a.description) || a.name || a.id
91}
92
93export function cut(s: string, n: number): string {
94 const chars = Array.from(s)
95 return chars.length <= n ? s : chars.slice(0, Math.max(0, n)).join('')
96}
97
98const len = (s: string) => Array.from(s).length
99
100/** Breaks text into pieces of at most `width` characters, at spaces where it can. */
101export function wrapText(s: string, width: number): string[] {
102 const w = Math.max(8, width)
103 const out: string[] = []
104 let rest = oneLine(s)
105 while (len(rest) > w) {
106 const head = cut(rest, w)
107 const sp = head.lastIndexOf(' ')
108 const take = sp > w / 3 ? head.slice(0, sp) : head
109 out.push(take)
110 rest = rest.slice(take.length).trimStart()
111 }
112 if (rest || out.length === 0) out.push(rest)
113 return out
114}
115
116export function markOf(a: AgentRecord): string {
117 return (a.isFork ? FORK_MARK + ' ' : '') + (a.isTeammate ? TEAMMATE_MARK + ' ' : '')
118}
119
120export function fmtIdle(ms: number): string {
121 const m = Math.floor(ms / 60_000)
122 if (m >= 60) return Math.floor(m / 60) + 'h' + (m % 60 ? (m % 60) + 'm' : '')
123 if (m >= 1) return m + 'm'
124 return Math.floor(ms / 1000) + 's'
125}
126
127/** The note on an agent's row: waiting on the person, idle too long, or muted. */
128export function flagOf(a: AgentRecord, now: number, stuckAfterMs: number): { text: string; color?: string } | undefined {
129 if (a.status !== 'running') return a.status === 'stopped' && a.stoppedBy ? { text: 'by ' + a.stoppedBy } : undefined
130 if (a.waiting?.length) return { text: '⏸ waiting: ' + Array.from(new Set(a.waiting.map(w => w.tool))).join(', '), color: 'claude' }
131 if (a.muted) return { text: '⊘ muted', color: 'permission' }
132 if (isStuck(a, now, stuckAfterMs)) return { text: '⚠ idle ' + fmtIdle(idleMs(a, now)), color: 'error' }
133 return undefined
134}
135
136/** The agent's line after its glyph, sized so prefix + glyph + line (+ reserved room) fits in `columns`. */
137export function agentLine(a: AgentRecord, now: number, columns: number, prefixLen: number, note = ''): string {
138 const fields = [
139 a.name || 'agent',
140 shortModel(a.model),
141 fmtElapsed(elapsedOf(a, now)),
142 a.toolCalls + ' tools',
143 fmtTokens(totalTokens(a.tokens)) + ' tok',
144 ...(note ? [note] : []),
145 ].join(' ')
146 const room = columns - prefixLen - 2 - 2 - len(fields)
147 const labelMax = Math.max(4, Math.min(LABEL_MAX, room))
148 const line = markOf(a) + cut(labelOf(a), labelMax).trimEnd() + ' ' + fields
149 return cut(line, Math.max(4, columns - prefixLen - 2))
150}
151
152function messageRows(agents: Agents, a: AgentRecord, prefix: string, columns: number): Row[] {
153 const byTarget = new Map<string, number>()
154 for (const m of a.messages) if (m.to) byTarget.set(m.to, (byTarget.get(m.to) ?? 0) + 1)
155 const rows: Row[] = []
156 for (const [to, n] of byTarget) {
157 const target = to === MAIN ? 'main' : labelOf(agents[to], to)
158 const body = cut('→ msg to ' + cut(target, LABEL_MAX) + (n > 1 ? ' x' + n : ''), Math.max(4, columns - len(prefix)))
159 rows.push({ kind: 'msg', id: a.id, prefix, dim: true, body })
160 }
161 return rows
162}
163
164function detailRows(agents: Agents, a: AgentRecord, prefix: string, now: number, columns: number): Row[] {
165 return detailLines(agents, a, now, columns - len(prefix)).map(body => ({ kind: 'detail' as const, id: a.id, prefix, dim: true, body }))
166}
167
168/** An agent's details, wrapped to `columns`: description, prompt, tools, tokens, status and model. */
169export function detailLines(agents: Agents, a: AgentRecord, now: number, columns: number): string[] {
170 const width = Math.max(8, columns)
171 const tools = Object.entries(a.toolsByName)
172 .sort((x, y) => y[1] - x[1])
173 .map(([k, v]) => k + ' ' + v)
174 .join(' · ')
175 const t = a.tokens
176 const parent = a.parent === MAIN || !a.parent ? 'main' : labelOf(agents[a.parent], a.parent)
177 const flags = [
178 a.isFork ? 'fork' : '',
179 a.isTeammate ? 'teammate' : '',
180 a.isBackground ? 'background' : '',
181 a.muted ? 'muted' : '',
182 a.stoppedBy ? 'stopped by ' + a.stoppedBy : '',
183 ].filter(Boolean)
184 const lines = [
185 'description: ' + (oneLine(a.description) || '-'),
186 'prompt: ' + (cut(oneLine(a.prompt), PROMPT_EXCERPT) || '-'),
187 'tools: ' + (tools || 'none') + (a.lastTool ? ' (last ' + a.lastTool + ')' : ''),
188 ...(a.activity ? ['last: ' + a.activity] : []),
189 'tokens: in ' + fmtTokens(t.input) + ' · out ' + fmtTokens(t.output) + ' · cache read ' + fmtTokens(t.cacheRead) + ' · cache write ' + fmtTokens(t.cacheCreate),
190 'status: ' + a.status + ' · ' + fmtElapsed(elapsedOf(a, now)) + ' · ' + a.requests + ' requests · from ' + parent + (flags.length ? ' · ' + flags.join(', ') : ''),
191 'model: ' + (a.model || '?') + ((a.returnedTokens ?? 0) > 0 ? ' · ' + returnedNote(agents, a) : ''),
192 ]
193 return lines.flatMap(l => wrapText(l, width))
194}
195
196export type TreeOptions = {
197 now: number
198 columns: number
199 expanded?: readonly string[]
200 /** Idle threshold for the `⚠ idle` note; 0 turns it off. */
201 stuckAfterMs?: number
202 /** Columns kept free on a running agent's row (the pane's Stop button). */
203 reserve?: number
204}
205
206/** The spawned agents to draw, in spawn order. */
207export function visibleIds(agents: Agents, order: readonly string[]): string[] {
208 const ids = order.filter(id => id !== MAIN && agents[id]?.isSpawned)
209 for (const id of Object.keys(agents)) if (id !== MAIN && agents[id].isSpawned && !ids.includes(id)) ids.push(id)
210 return ids
211}
212
213/** Every row of the map: the main loop's header, then the agents as a tree. */
214export function buildRows(agents: Agents, order: readonly string[], opts: TreeOptions): Row[] {
215 const { now, columns } = opts
216 const expanded = new Set(opts.expanded ?? [])
217 const stuckAfterMs = opts.stuckAfterMs ?? DEFAULT_STUCK_MS
218 const reserve = Math.max(0, opts.reserve ?? 0)
219 const agentRow = (id: string, lead: string): Row => {
220 const a = agents[id]
221 const g = glyphOf(a.status)
222 const running = a.status === 'running'
223 const flag = flagOf(a, now, stuckAfterMs)
224 const flagText = flag ? ' ' + flag.text : ''
225 const room = columns - (running ? reserve : 0)
226 const body = agentLine(a, now, Math.max(len(lead) + 8, room - len(flagText)), len(lead), returnedNote(agents, a))
227 const row: Row = { kind: 'agent', id, prefix: lead, glyph: g.glyph, color: g.color, dim: g.dim, body, canStop: running }
228 if (flag) {
229 row.flag = cut(flagText, Math.max(0, room - len(lead) - 2 - len(body)))
230 if (flag.color) row.flagColor = flag.color
231 if (!row.flag.trim()) delete row.flag
232 }
233 return row
234 }
235 const activityRow = (a: AgentRecord, prefix: string): Row[] =>
236 a.status === 'running' && a.activity
237 ? [{ kind: 'activity', id: a.id, prefix, dim: true, body: cut('⋯ ' + a.activity, Math.max(4, columns - len(prefix))) }]
238 : []
239 const main = agents[MAIN]
240 const header =
241 'main (orchestrator) · ' + fmtTokens(totalTokens(main?.tokens)) + ' tok · ' + (main?.toolCalls ?? 0) + ' tools'
242 const mainWaits = main?.waiting?.length ? ' · ⏸ waiting: ' + Array.from(new Set(main.waiting.map(w => w.tool))).join(', ') : ''
243 const rows: Row[] = [{ kind: 'header', id: MAIN, prefix: '', body: cut(header + mainWaits, Math.max(8, columns)) }]
244 const ids = visibleIds(agents, order)
245 const shown = new Set(ids)
246 const parentOf = (id: string) => {
247 const p = agents[id].parent
248 return p && p !== id && shown.has(p) ? p : MAIN
249 }
250 if (main) rows.push(...messageRows(agents, main, ids.length ? '│ ' : '', columns))
251 if (ids.length === 0) {
252 rows.push({ kind: 'empty', prefix: '', dim: true, body: 'no subagents yet' })
253 return rows
254 }
255 const placed = new Set<string>()
256 const walk = (pid: string, prefix: string) => {
257 const kids = ids.filter(id => !placed.has(id) && parentOf(id) === pid)
258 kids.forEach(id => placed.add(id))
259 kids.forEach((id, i) => {
260 const a = agents[id]
261 const last = i === kids.length - 1
262 const lead = prefix + (last ? '└─ ' : '├─ ')
263 const cont = prefix + (last ? ' ' : '│ ')
264 rows.push(agentRow(id, lead))
265 rows.push(...activityRow(a, cont))
266 if (expanded.has(id)) rows.push(...detailRows(agents, a, cont + ' ', now, columns))
267 rows.push(...messageRows(agents, a, cont, columns))
268 walk(id, cont)
269 })
270 }
271 walk(MAIN, '')
272 // A parent cycle leaves agents unplaced: draw them at the top level
273 for (const id of ids) {
274 if (placed.has(id)) continue
275 placed.add(id)
276 rows.push(agentRow(id, '└─ '))
277 rows.push(...activityRow(agents[id], ' '))
278 }
279 return rows
280}
281
282export function rowText(r: Row): string {
283 return r.prefix + (r.glyph ? r.glyph + ' ' : '') + r.body + (r.flag ?? '')
284}
285
286export function treeText(agents: Agents, order: readonly string[], now: number, columns = TEXT_COLUMNS, stuckAfterMs = DEFAULT_STUCK_MS): string {
287 return buildRows(agents, order, { now, columns, stuckAfterMs }).map(rowText).join('\n')
288}
289
290export type BandExtra = { caps?: CapConfig; counters?: CapCounters }
291
292/** Where the caps stand: a spawn was denied this turn or a cap is reached, past warnAt, or neither. */
293export type BandState = 'ok' | 'near cap' | 'at cap'
294
295/** One chip of the band: its whole text (glyph included) and its tone. */
296export type BandChip = { text: string; tone: Tone }
297
298/** The theme key of each state, as the state chip draws it (near cap is 'claude', not the warning key). */
299export const BAND_COLORS: Record<BandState, string> = { ok: 'success', 'near cap': 'claude', 'at cap': 'error' }
300const BAND_TONES: Record<BandState, Tone> = { ok: 'ok', 'near cap': 'accent', 'at cap': 'danger' }
301
302/** The band's chips: `● 3/4 running`, `✓ 5 done`, `✗ 1` and the `● ok` state chip; a zero count is muted. */
303export function bandChips(c: Counts, state: BandState, caps: CapConfig = DEFAULT_CAPS): BandChip[] {
304 const running = caps.maxConcurrent > 0 ? c.running + '/' + caps.maxConcurrent : String(c.running)
305 return [
306 { text: '● ' + running + ' running', tone: c.running > 0 ? 'info' : 'muted' },
307 { text: '✓ ' + c.done + ' done', tone: c.done > 0 ? 'ok' : 'muted' },
308 { text: '✗ ' + c.failed, tone: c.failed > 0 ? 'danger' : 'muted' },
309 { text: '● ' + state, tone: BAND_TONES[state] },
310 ]
311}
312
313/** The band's quiet right-hand side: aborted and stopped counts, tokens, and results returned to main. */
314export function bandRight(c: Counts, extra: BandExtra = {}): string[] {
315 const returned = extra.counters?.returnedTotal ?? 0
316 const out: string[] = []
317 if (c.aborted > 0) out.push('○ ' + c.aborted + ' aborted')
318 if (c.stopped > 0) out.push('■ ' + c.stopped + ' stopped')
319 out.push(fmtTokens(c.tokens) + ' tok')
320 // Results under 1k tokens are noise next to a context window
321 if (returned >= 1000) out.push('results ' + fmtTokens(returned) + ' → main')
322 return out
323}
324
325/** The band as one line of text: `agents ● 3/4 running ✓ 5 done ✗ 1 ● ok 412k tok · results 96k → main`. */
326export function bandText(c: Counts, extra: BandExtra = {}, state: BandState = 'ok'): string {
327 const chips = bandChips(c, state, extra.caps ?? DEFAULT_CAPS).map(x => x.text)
328 return ['agents', ...chips, bandRight(c, extra).join(' · ')].join(' ')
329}
330
331/** True once the results returned to main pass warnAt of maxReturnedTokens. */
332export function returnedNear(caps: CapConfig, counters: CapCounters): boolean {
333 return caps.maxReturnedTokens > 0 && counters.returnedTotal >= caps.warnAt * caps.maxReturnedTokens
334}
335
336/** The cap state: at cap once a spawn was denied this turn or a cap is reached, near cap past warnAt, else ok. */
337export function bandStateOf(extra: BandExtra = {}, near = false, at = false): BandState {
338 if (extra.counters?.deniedThisTurn || at) return 'at cap'
339 return near ? 'near cap' : 'ok'
340}
341
342/** The state's theme key. */
343export function bandColor(state: BandState): string {
344 return BAND_COLORS[state]
345}
346
347export type Band = { text: string; color: string; state: BandState; chips: BandChip[]; right: string[]; counts: Counts }
348
349export function bandOf(agents: Agents, extra: BandExtra = {}): Band {
350 const counts = countsOf(agents)
351 const caps = extra.caps ?? DEFAULT_CAPS
352 const counters = extra.counters ?? zeroCounters()
353 const uses = capUses(runningCount(agents), counters, caps)
354 const near = nearAnyCap(uses, caps.warnAt) || returnedNear(caps, counters)
355 const at = uses.some(u => u.max > 0 && u.n >= u.max) || (caps.maxReturnedTokens > 0 && counters.returnedTotal >= caps.maxReturnedTokens)
356 const state = bandStateOf(extra, near, at)
357 return { text: bandText(counts, extra, state), color: bandColor(state), state, chips: bandChips(counts, state, caps), right: bandRight(counts, extra), counts }
358}
359
360// ---- Cards view (0.3.0) ----
361
362export const CARD_LABEL = 32
363
364const pad2c = (n: number) => (n < 10 ? '0' : '') + n
365
366/** A wall-clock time in the runtime's local zone: `10:31`, or `10:31:05` with seconds. */
367export function fmtClock(ms: number, seconds = false): string {
368 const d = new Date(ms)
369 const hm = pad2c(d.getHours()) + ':' + pad2c(d.getMinutes())
370 return seconds ? hm + ':' + pad2c(d.getSeconds()) : hm
371}
372
373export function returnedNote(agents: Agents, a: AgentRecord): string {
374 const n = a.returnedTokens ?? 0
375 if (n <= 0) return ''
376 const to = a.parent === MAIN || !a.parent ? 'main' : cut(labelOf(agents[a.parent], a.parent), 16)
377 return '→ ' + fmtTokens(n) + ' to ' + to
378}
379
380/** Running agents first, then the ones waiting on the person, then the finished. */
381export function cardOrder(agents: Agents, order: readonly string[]): string[] {
382 const ids = visibleIds(agents, order)
383 const rank = (a: AgentRecord) => (a.status !== 'running' ? 2 : a.waiting?.length ? 1 : 0)
384 return ids
385 .map((id, i) => ({ id, i, r: rank(agents[id]) }))
386 .sort((x, y) => x.r - y.r || x.i - y.i)
387 .map(x => x.id)
388}
389
390/** `spawned 10:31 → running 00:57 → ⏸ waiting (Bash) → done 10:38` */
391export function lifecycleLine(a: AgentRecord, now: number, stuckAfterMs: number): string {
392 const parts = ['spawned ' + fmtClock(a.startedAt), 'running ' + fmtElapsed(elapsedOf(a, now))]
393 if (a.status === 'running') {
394 if (a.waiting?.length) parts.push('⏸ waiting (' + Array.from(new Set(a.waiting.map(w => w.tool))).join(', ') + ')')
395 else if (a.muted) parts.push('⊘ muted')
396 else if (isStuck(a, now, stuckAfterMs)) parts.push('⚠ idle ' + fmtIdle(idleMs(a, now)))
397 } else {
398 parts.push(a.status + ' ' + fmtClock(a.endedAt ?? now) + (a.status === 'stopped' && a.stoppedBy ? ' by ' + a.stoppedBy : ''))
399 }
400 return parts.join(' → ')
401}
402
403export type Card = {
404 id: string
405 glyph: string
406 color?: string
407 /** label · type · model, with the fork or teammate mark in front. */
408 title: string
409 lifecycle: string
410 stats: string
411 parent: string
412 canStop: boolean
413}
414
415/** One card's four lines, each cut to `width` (the inside of the card's border). */
416export function cardOf(agents: Agents, a: AgentRecord, now: number, width: number, stuckAfterMs = DEFAULT_STUCK_MS): Card {
417 const w = Math.max(8, width)
418 const g = glyphOf(a.status)
419 const title = markOf(a) + cut(labelOf(a), CARD_LABEL).trimEnd() + ' · ' + (a.name || 'agent') + ' · ' + shortModel(a.model)
420 const stats = [
421 a.toolCalls + ' tool' + (a.toolCalls === 1 ? '' : 's'),
422 a.activity ? 'last ' + a.activity : '',
423 fmtTokens(totalTokens(a.tokens)) + ' tok',
424 returnedNote(agents, a),
425 ]
426 .filter(Boolean)
427 .join(' · ')
428 const parent = 'from ' + (a.parent === MAIN || !a.parent ? 'main' : labelOf(agents[a.parent], a.parent))
429 const running = a.status === 'running'
430 return {
431 id: a.id,
432 glyph: g.glyph,
433 color: g.color,
434 // Room for ' ' + glyph after the title
435 title: cut(title, w - 2),
436 lifecycle: cut(lifecycleLine(a, now, stuckAfterMs), w),
437 stats: cut(stats, w),
438 parent: cut(parent, running ? Math.max(4, w - 14) : w),
439 canStop: running,
440 }
441}
442
443export function cardsOf(agents: Agents, order: readonly string[], now: number, columns: number, stuckAfterMs = DEFAULT_STUCK_MS): Card[] {
444 // A card's border and padding take 4 columns
445 return cardOrder(agents, order).map(id => cardOf(agents, agents[id], now, columns - 4, stuckAfterMs))
446}
447
448/** The cards as text, each in a rounded border, for /agent-map text and tests. */
449export function cardsText(agents: Agents, order: readonly string[], now: number, columns = TEXT_COLUMNS, stuckAfterMs = DEFAULT_STUCK_MS): string {
450 const cards = cardsOf(agents, order, now, columns, stuckAfterMs)
451 if (cards.length === 0) return 'no subagents yet'
452 const inner = columns - 4
453 const line = (s: string) => '│ ' + s + ' '.repeat(Math.max(0, inner - len(s))) + ' │'
454 return cards
455 .map(c =>
456 [
457 '╭' + '─'.repeat(columns - 2) + '╮',
458 line(c.title + ' ' + c.glyph),
459 line(c.lifecycle),
460 line(c.stats),
461 line(c.parent + (c.canStop ? ' [Stop]' : '')),
462 '╰' + '─'.repeat(columns - 2) + '╯',
463 ].join('\n'),
464 )
465 .join('\n')
466}
467
468// ---- Team view (0.3.0) ----
469
470export function laneHeader(agents: Agents, ids: readonly string[], title: string): string {
471 const n = { running: 0, done: 0, failed: 0, other: 0 }
472 for (const id of ids) {
473 const s = agents[id].status
474 if (s === 'running') n.running++
475 else if (s === 'done') n.done++
476 else if (s === 'failed') n.failed++
477 else n.other++
478 }
479 const parts = [n.running + ' running', n.done + ' done']
480 if (n.failed) parts.push(n.failed + ' failed')
481 if (n.other) parts.push(n.other + ' ended')
482 return title + ': ' + parts.join(' · ')
483}
484
485/**
486 * Agents grouped by parent into lanes: the main loop's children first, then
487 * each agent's children in a lane indented under its parent's, and the
488 * teammates (◇) in a lane of their own.
489 */
490export function teamRows(agents: Agents, order: readonly string[], opts: TreeOptions): Row[] {
491 const { now, columns } = opts
492 const stuckAfterMs = opts.stuckAfterMs ?? DEFAULT_STUCK_MS
493 const reserve = Math.max(0, opts.reserve ?? 0)
494 const ids = visibleIds(agents, order)
495 const shown = new Set(ids)
496 const parentOf = (id: string) => {
497 const p = agents[id].parent
498 return p && p !== id && shown.has(p) ? p : MAIN
499 }
500 const rows: Row[] = []
501 if (ids.length === 0) return [{ kind: 'empty', prefix: '', dim: true, body: 'no subagents yet' }]
502 const agentRow = (id: string, lead: string): Row => {
503 const a = agents[id]
504 const g = glyphOf(a.status)
505 const running = a.status === 'running'
506 const flag = flagOf(a, now, stuckAfterMs)
507 const tail = flag ? ' ' + flag.text : ''
508 const room = columns - (running ? reserve : 0)
509 const body = agentLine(a, now, Math.max(len(lead) + 8, room - len(tail)), len(lead), returnedNote(agents, a))
510 const row: Row = { kind: 'agent', id, prefix: lead, glyph: g.glyph, color: g.color, dim: g.dim, body, canStop: running }
511 const t = cut(tail, Math.max(0, room - len(lead) - 2 - len(body)))
512 if (t.trim()) {
513 row.flag = t
514 if (flag?.color) row.flagColor = flag.color
515 }
516 return row
517 }
518 const done = new Set<string>()
519 const lane = (pid: string, depth: number, members: string[], title: string) => {
520 if (members.length === 0) return
521 const indent = ' '.repeat(depth)
522 rows.push({ kind: 'lane', id: pid, prefix: indent, body: cut(laneHeader(agents, members, title), Math.max(4, columns - len(indent))) })
523 for (const id of members) {
524 done.add(id)
525 rows.push(agentRow(id, indent + ' '))
526 }
527 }
528 const walk = (pid: string, depth: number) => {
529 const kids = ids.filter(id => !done.has(id) && !agents[id].isTeammate && parentOf(id) === pid)
530 const title = 'team of ' + (pid === MAIN ? 'main' : cut(labelOf(agents[pid], pid), LABEL_MAX))
531 lane(pid, depth, kids, title)
532 for (const id of kids) walk(id, depth + 1)
533 }
534 walk(MAIN, 0)
535 const mates = ids.filter(id => !done.has(id) && agents[id].isTeammate)
536 lane('teammates', 0, mates, 'teammates')
537 for (const id of mates) walk(id, 1)
538 // A parent cycle leaves agents out: they get a lane of their own
539 lane('other', 0, ids.filter(id => !done.has(id)), 'unattached')
540 return rows
541}
542
543export function teamText(agents: Agents, order: readonly string[], now: number, columns = TEXT_COLUMNS, stuckAfterMs = DEFAULT_STUCK_MS): string {
544 return teamRows(agents, order, { now, columns, stuckAfterMs }).map(rowText).join('\n')
545}
546
547// ---- Lifecycle log (0.3.0) ----
548
549export const LOG_TEXT_LINES = 40
550export const LOG_PANE_LINES = 12
551
552export function logLine(e: LogEntry): string {
553 return fmtClock(e.at, true) + ' ' + e.kind + ' ' + e.label + (e.text ? ' · ' + e.text : '')
554}
555
556export function logText(log: readonly LogEntry[], n = LOG_TEXT_LINES): string {
557 if (log.length === 0) return 'agent-map log: no lifecycle events yet'
558 return log.slice(-n).map(logLine).join('\n')
559}
560
561export function logRows(log: readonly LogEntry[], columns: number, n = LOG_PANE_LINES): Row[] {
562 const rows: Row[] = [{ kind: 'lane', prefix: '', body: 'log (last ' + Math.min(n, log.length) + ')' }]
563 if (log.length === 0) rows.push({ kind: 'log', prefix: '', dim: true, body: 'no lifecycle events yet' })
564 for (const e of log.slice(-n)) rows.push({ kind: 'log', id: e.id, prefix: '', dim: true, body: cut(logLine(e), Math.max(4, columns)) })
565 return rows
566}
567
568// ---- Card look (0.5.0): the effort word and the critter avatar ----
569
570/** An effort as the card shows it: the word, its tone, and the critter's hat color. */
571export type EffortLook = { word: string; tone: Tone; hat: string }
572
573const EFFORTS: Record<string, EffortLook> = {
574 low: { word: 'light', tone: 'ok', hat: '#22A35A' },
575 medium: { word: 'medium', tone: 'info', hat: '#3B82F6' },
576 high: { word: 'careful', tone: 'accent', hat: '#D9901A' },
577 xhigh: { word: 'thorough', tone: 'accent', hat: '#E5484D' },
578 max: { word: 'max', tone: 'danger', hat: '#E5484D' },
579}
580const NO_EFFORT: EffortLook = { word: '', tone: 'info', hat: '#64748B' }
581
582/** low → light, medium → medium, high → careful, xhigh → thorough, max → max; anything else draws no word. */
583export function effortLook(effort: string): EffortLook {
584 return EFFORTS[String(effort || '').trim().toLowerCase()] ?? NO_EFFORT
585}
586
587/** The critter's state: a pennant while running, X eyes when failed, faded once finished. */
588export type CritterState = 'running' | 'waiting' | 'done' | 'failed' | 'idle'
589
590const CRITTER = { body: '#C96F4A', outline: '#8A4A32', eye: '#1A1A1A', crown: '#8B5CF6', stick: '#8A4A32', pennant: '#E5484D' }
591export const CRITTER_SIZE = 44
592
593// 12×12 cells. H = hat (or the main loop's crown), O = outline, B = body, E = eye, S = stick, P = pennant.
594const HAT = ['...HHH......', '..HHHHH.....', '.HHHHHHH....']
595const CROWN = ['.H..H..H....', '.HHHHHHH....', '.HHHHHHH....']
596const BODY = [
597 '.OOOOOOO....',
598 'OBBBBBBBO...',
599 'OBEBBBEBO...',
600 'OBEBBBEBO...',
601 'OBBBBBBBB...',
602 'OBBBBBBBO...',
603 '.OBBBBBO....',
604 '.OO...OO....',
605 '.OO...OO....',
606]
607const POLE = ['.........SPP', '.........SPP', '.........SP.', '.........S..', '.........S..', '.........S..', '.........S..', '.........S..']
608
609const critterCache = new Map<string, string>()
610
611/**
612 * The critter avatar as an SVG document: a terracotta pixel creature with an effort-colored hat
613 * (a violet crown for the main loop), a pennant on a stick while it runs, X eyes when it failed,
614 * and 0.8 opacity once it is done, stopped or aborted.
615 */
616export function critterSvg(o: { main?: boolean; hat?: string; state?: CritterState; faded?: boolean; size?: number }): string {
617 const main = o.main === true
618 const hat = /^#[0-9a-f]{6}$/i.test(o.hat ?? '') ? (o.hat as string) : NO_EFFORT.hat
619 const state: CritterState = o.state ?? 'idle'
620 const faded = o.faded === true || state === 'done'
621 const size = typeof o.size === 'number' && o.size >= 8 && o.size <= 256 ? Math.round(o.size) : CRITTER_SIZE
622 const key = [main ? 'm' : 'a', hat, state, faded ? 'f' : '', size].join('|')
623 const hit = critterCache.get(key)
624 if (hit !== undefined) return hit
625 const rows = [...(main ? CROWN : HAT), ...BODY]
626 if (state === 'running') POLE.forEach((p, y) => (rows[y] = rows[y].slice(0, 9) + p.slice(9)))
627 const fills: Record<string, string> = {
628 H: main ? CRITTER.crown : hat,
629 O: CRITTER.outline,
630 B: CRITTER.body,
631 E: state === 'failed' ? CRITTER.body : CRITTER.eye,
632 S: CRITTER.stick,
633 P: CRITTER.pennant,
634 }
635 let rects = ''
636 rows.forEach((line, y) => {
637 let x = 0
638 while (x < 12) {
639 const ch = line[x] ?? '.'
640 let end = x + 1
641 while (end < 12 && line[end] === ch) end++
642 if (fills[ch]) rects += `<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${fills[ch]}"/>`
643 x = end
644 }
645 })
646 // X eyes: two crossed strokes over each eye
647 if (state === 'failed') {
648 rects += `<path d="M1.7 4.8 3.3 7.2M3.3 4.8 1.7 7.2M5.7 4.8 7.3 7.2M7.3 4.8 5.7 7.2" stroke="${CRITTER.eye}" stroke-width="0.5"/>`
649 }
650 const svg =
651 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="${size}" height="${size}" shape-rendering="crispEdges">` +
652 `<g${faded ? ' opacity="0.8"' : ''}>${rects}</g></svg>`
653 if (critterCache.size >= 64) critterCache.clear()
654 critterCache.set(key, svg)
655 return svg
656}
657hooks/kit.ts 955 lines1// kit.ts: the shared UI kit (kit v3).
2//
3// The module has no imports and touches no mods API: every helper takes the elements a hook got
4// from `$.ui.resolve(e)` and the surface (`e.surface`) and returns an element tree.
5//
6// const k = makeKit($.ui.resolve(e) as unknown as Elements, e.surface)
7// return k.stack([k.hero({ kicker: 'right now', title: 'Reading client.test.js' }), k.tiles([...])], 1)
8//
9// What v2 changed on purpose (v1 painted grey fills and dimmed grey text, unreadable on the desktop):
10// - colors are theme keys (suggestion, success, warning, error, claude, inactive) that the host
11// tunes for light and dark; PALETTE below swaps them for named colors in one line;
12// - no dimColor, no backgroundColor, no yellow, white, gray, black or subtle on content: secondary
13// text is `inactive`, state is a bold `● word` chip (glyph and word, so color is never the only cue);
14// - hero and card are one round frame (paddingX 1), tiles are single-border boxes with the label
15// over a bold value; terminal and desktop draw the same structure (the Svg doodle is desktop-only);
16// - kicker, section and tile labels keep the caller's case (v1 upper-cased kicker and section);
17// - additive: line(label, chips, right?) draws one band line.
18//
19// Calibration (claude plugin test, Claude Code 2.1.294): the test harness validates the tree a
20// ui.render hook returns (a Text with a bogus prop makes `$.ui.mount` reject on both surfaces), but
21// not how the desktop paints a theme key. A Button given `plain: false` throws when built (plain and
22// autoFocus take only `true`), so the kit leaves false and undefined props out; a Box inside a Text
23// is refused. doodle() draws its Svg on the desktop only (a pane holding only an Svg opens empty in
24// the terminal).
25//
26// What v3 adds (strictly additive: every v2 export above renders as before). The desktop paints
27// Svg, and its light theme made the v1 fills unreadable, so v3 draws small pictures as Svg wherever
28// the element table has one (desktop, mobile, vscode) and as text on the terminal:
29// - pill(text, tone?): a fully rounded Svg chip (PILL 'svg', the default); PILL 'fill' draws a
30// theme-key backgroundColor under `inverseText` instead (the only fill in the kit); the v2
31// `● text` chip on the terminal;
32// - header(label, right?): an upper-cased `inactive` section label with a right-hand count;
33// - icon(name, tone?), typeBadge(fileName) and progress(ratio, opts?): Svg icons, file-type badges
34// and a rounded bar; a glyph, a bold label and the v2 bar on the terminal;
35// - buttons() items take an optional `variant` ('primary' | 'secondary');
36// - nav(items, currentKey): a pane's nav bar, the current item bold Text, the others plain Buttons;
37// - pure helpers: pillSvg, shortPath and extOf; TONE_HEX, the Svg colors of icons and bars, and
38// CHIP_HEX, the darker pill fills that keep white text at 4.5:1 or more.
39// Svg markup cannot read theme keys, so it uses fixed mid-tone hexes that read on light and dark
40// grounds (never a color word), and each distinct drawing is built once and cached.
41//
42// What v3.1 adds (spacing; additive): on the desktop, blocks stacked with no gap touch border to
43// border and wrapped rows sit line on line. So every wrapping row (buttons, tiles, nav) gets rowGap 1,
44// buttons get columnGap 2, and two new columns space a pane out:
45// - page(children): a pane's top-level column, one row between blocks;
46// - list(children): a column of repeated cards or blocks, one row between them.
47// Rows inside one card or block stay tight (stack with no gap); stack() keeps its props. Bands that
48// draw buttons() are one row, so the new gaps change nothing there. buttons() items also take
49// `autoFocus` (passed only when true).
50
51export const KIT_VERSION = '3.1.0'
52
53/** How pill() draws on a surface with Svg: 'svg' (a fully rounded chip) or 'fill' (a theme-key
54 * background). makeKit's `pill` option overrides it for one kit. */
55export const PILL: 'fill' | 'svg' = 'svg'
56
57/** kit v3: makeKit's options. `pill` overrides PILL for this kit. */
58export type KitOptions = { pill?: 'fill' | 'svg' }
59
60/** 'theme': theme keys that follow the person's light or dark theme. 'named': named colors, for a
61 * host that does not paint theme keys (never yellow: warn is red plus its glyph). One-line switch. */
62export const PALETTE: 'theme' | 'named' = 'theme'
63
64export type Surface = 'terminal' | 'desktop' | (string & {})
65export type Tone = 'info' | 'ok' | 'warn' | 'danger' | 'muted' | 'accent'
66export type Role = 'main' | 'recon' | 'execute' | 'verify' | 'hard' | 'reviewer' | 'other'
67export type DoodleState = 'running' | 'done' | 'failed' | 'waiting' | 'idle'
68/** The tones of pill() and icon(): the six tones plus violet. */
69export type PillTone = Tone | 'violet'
70export type IconName =
71 | 'eye'
72 | 'pencil'
73 | 'plus'
74 | 'branch'
75 | 'search'
76 | 'folder'
77 | 'folder-open'
78 | 'file'
79 | 'shield'
80 | 'flag'
81 | 'check'
82 | 'dot'
83 | 'clock'
84/** What `$.ui.resolve(e)` returns, loosely typed so the kit compiles without the engine's types. */
85export type Elements = Record<string, (props: Record<string, unknown>) => unknown>
86
87export type Kit = {
88 hero(p: { kicker?: string; title: string; sub?: string; icon?: string; tone?: Tone }): unknown
89 card(p: { title?: string; right?: string; tone?: Tone; children: unknown[] }): unknown
90 section(label: string, right?: string): unknown
91 tiles(items: { label: string; value: string; tone?: Tone }[]): unknown
92 badge(text: string, tone?: Tone): unknown
93 legend(items: { tone: Tone; label: string }[]): unknown
94 bar(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
95 /** One row, left side truncating, right side muted. `align: 'center'` (kit v3) centers a row holding a pill. */
96 row(p: { left: unknown[] | string; right?: unknown[] | string; key?: string; align?: 'center' }): unknown
97 kv(label: string, value: string, tone?: Tone): unknown
98 buttons(
99 items: {
100 key: string
101 label: string
102 hotkey?: string
103 onPress: (e?: unknown) => unknown
104 plain?: boolean
105 /** kit v3: the Button's variant, passed only when set. */
106 variant?: 'primary' | 'secondary'
107 /** kit 3.1: focus this Button when the pane opens; passed only when true. */
108 autoFocus?: boolean
109 }[],
110 ): unknown
111 doodle(role: Role, state?: DoodleState): unknown
112 stack(children: unknown[], gap?: number): unknown
113 /** kit 3.1: a pane's top-level column, one row between its blocks; null children are dropped. */
114 page(children: unknown[]): unknown
115 /** kit 3.1: a column of repeated cards or blocks, one row between them; null children are dropped. */
116 list(children: unknown[]): unknown
117 text(s: string, props?: Record<string, unknown>): unknown
118 /**
119 * One band line: `label ● a ● b right · text`. A chip is `{ text, tone? }`, a string (a muted
120 * chip) or an element such as badge(); at most four are drawn. `right` is a string or a list
121 * joined with ` · `, drawn `inactive` and truncated. One row high.
122 */
123 line(label: string, chips: unknown[], right?: string | string[]): unknown
124 /** kit v3: a rounded chip (Svg surfaces) or the v2 `● text` chip (terminal). */
125 pill(text: string, tone?: Tone | 'violet'): unknown
126 /** kit v3: one row, the label upper-cased, bold and `inactive`; `right` upper-cased, right-aligned. */
127 header(label: string, right?: string): unknown
128 /** kit v3: a 16×16 stroke icon (Svg surfaces) or one glyph in the tone (terminal). */
129 icon(name: IconName, tone?: Tone | 'violet'): unknown
130 /** kit v3: a file-type badge ('JS', 'PDF', …) for a file name; null when the name has no extension. */
131 typeBadge(fileName: string): unknown
132 /** kit v3: a rounded bar (Svg surfaces; `width` in CSS pixels, absent: the slot's width) or bar(). */
133 progress(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
134 /**
135 * kit v3: a pane's nav bar, one row keyed 'nav': the item whose key is `currentKey` as bold Text in the accent
136 * tone (not pressable), the others plain Buttons with their hotkeys (`1: Stage`); one without onPress quiet Text.
137 */
138 nav(items: { key: string; label: string; hotkey?: string; onPress?: () => unknown }[], currentKey: string): unknown
139 surface: Surface
140}
141
142/** The quiet color: secondary text, labels, units, timestamps, frame and tile borders. */
143const QUIET = 'inactive'
144
145const THEME_TONES: Record<Tone, string> = {
146 info: 'suggestion',
147 ok: 'success',
148 warn: 'warning',
149 danger: 'error',
150 muted: QUIET,
151 accent: 'claude',
152}
153
154const NAMED_TONES: Record<Tone, string> = {
155 info: 'blue',
156 ok: 'green',
157 warn: 'red',
158 danger: 'red',
159 muted: QUIET,
160 accent: 'magenta',
161}
162
163const TONE_COLORS = PALETTE === 'named' ? NAMED_TONES : THEME_TONES
164
165/** fg: the color of the tone, a theme key (a named color when PALETTE is 'named'). bg equals fg: v2
166 * draws no fills; the field stays so v1 callers compile. */
167export const TONES: Record<Tone, { fg: string; bg: string }> = {
168 info: { fg: TONE_COLORS.info, bg: TONE_COLORS.info },
169 ok: { fg: TONE_COLORS.ok, bg: TONE_COLORS.ok },
170 warn: { fg: TONE_COLORS.warn, bg: TONE_COLORS.warn },
171 danger: { fg: TONE_COLORS.danger, bg: TONE_COLORS.danger },
172 muted: { fg: TONE_COLORS.muted, bg: TONE_COLORS.muted },
173 accent: { fg: TONE_COLORS.accent, bg: TONE_COLORS.accent },
174}
175
176/**
177 * kit v3: the Svg colors of icons and bars, a mid-tone hex per tone that reads on light and dark grounds
178 * (Svg markup cannot read theme keys). warn and accent share the Claude orange: the theme's warning
179 * color is unreadable as a fill. A pill fills with CHIP_HEX instead.
180 *
181 * info #3B82F6 ok #22A35A warn #D97757 danger #E5484D accent #D97757 muted #8A8F98
182 * violet #8B5CF6
183 */
184export const TONE_HEX: Record<PillTone, string> = {
185 info: '#3B82F6',
186 ok: '#22A35A',
187 warn: '#D97757',
188 danger: '#E5484D',
189 accent: '#D97757',
190 muted: '#8A8F98',
191 violet: '#8B5CF6',
192}
193
194/**
195 * kit v3: a pill's fill per tone, darker than TONE_HEX so its white text has a contrast ratio of at
196 * least 4.5:1 (WCAG AA for small text). The ratios against white:
197 *
198 * info #2563EB 5.17 ok #15803D 5.02 warn #C2410C 5.18 danger #B91C1C 6.47 accent #C2410C 5.18
199 * muted #5B6170 6.20 violet #7C3AED 5.70
200 */
201export const CHIP_HEX: Record<PillTone, string> = {
202 info: '#2563EB',
203 ok: '#15803D',
204 warn: '#C2410C',
205 danger: '#B91C1C',
206 accent: '#C2410C',
207 muted: '#5B6170',
208 violet: '#7C3AED',
209}
210
211/** kit v3: the theme key a pill fills with, per tone (warn is 'claude': a 'warning' fill is unreadable). */
212const PILL_FILLS: Record<PillTone, string> = {
213 info: 'suggestion',
214 ok: 'success',
215 warn: 'claude',
216 danger: 'error',
217 accent: 'claude',
218 muted: QUIET,
219 violet: 'permission',
220}
221
222/** kit v3: the stroke of an icon drawn without a tone. */
223const ICON_STROKE = '#6B7280'
224/** kit v3: the track of progress(): the muted hex at quarter opacity. */
225const TRACK_HEX = '#8A8F98'
226/** kit v3: the text on a light fill (the amber JS badge); light text everywhere else. */
227const INK_DARK = '#1A1A1A'
228const INK_LIGHT = '#FFFFFF'
229
230/** kit v3: icon markup on a 16×16 grid, stroked with `currentColor`'s stand-in `S`. */
231const ICONS: Record<IconName, string> = {
232 eye: '<path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/>',
233 pencil: '<path d="M11 2.5 13.5 5 5.5 13H3v-2.5z"/><path d="M9.5 4 12 6.5"/>',
234 plus: '<path d="M8 3v10M3 8h10"/>',
235 branch:
236 '<circle cx="4.5" cy="3.5" r="1.5"/><circle cx="4.5" cy="12.5" r="1.5"/><circle cx="11.5" cy="5" r="1.5"/>' +
237 '<path d="M4.5 5v6M11.5 6.5c0 2.5-2 3.5-4 3.8-1.6.2-3 .7-3 .7"/>',
238 search: '<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14"/>',
239 folder: '<path d="M1.75 3.25h4.5l1.5 1.75h6.5v7.75H1.75z"/>',
240 'folder-open': '<path d="M1.75 12.75v-9.5h4.5l1.5 1.75h5v2"/><path d="M1.75 12.75 3.75 7h10.5l-2 5.75z"/>',
241 file: '<path d="M9 1.75H3v12.5h10v-8.5z"/><path d="M9 1.75v4h4"/>',
242 shield: '<path d="M8 1.75 13 3.75v4c0 3.2-2.2 5.4-5 6.5-2.8-1.1-5-3.3-5-6.5v-4z"/>',
243 flag: '<path d="M3 14.5V2"/><path d="M3 2.5h8.5L9.75 5.5l1.75 3H3"/>',
244 check: '<path d="M3 8.5 6 11.5 13 4.5"/>',
245 dot: '<circle cx="8" cy="8" r="3" fill="S"/>',
246 clock: '<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/>',
247}
248
249/** kit v3: the terminal glyph of each icon. */
250const ICON_GLYPHS: Record<IconName, string> = {
251 eye: '◉',
252 pencil: '✎',
253 plus: '+',
254 branch: '⎇',
255 search: '⌕',
256 folder: '▸',
257 'folder-open': '▾',
258 file: '·',
259 shield: '⛨',
260 flag: '⚑',
261 check: '✓',
262 dot: '●',
263 clock: '◷',
264}
265
266/** kit v3: file-type badges by extension: the label, its Svg fill and its terminal tone. */
267const FILE_TYPES: { exts: string[]; label: string; fill: string; tone: Tone }[] = [
268 { exts: ['js', 'jsx', 'mjs', 'cjs'], label: 'JS', fill: '#F0C419', tone: 'accent' },
269 { exts: ['ts', 'tsx'], label: 'TS', fill: '#3178C6', tone: 'info' },
270 { exts: ['pdf'], label: 'PDF', fill: '#E5484D', tone: 'danger' },
271 { exts: ['md', 'mdx'], label: 'MD', fill: '#4A7BD0', tone: 'info' },
272 { exts: ['doc', 'docx'], label: 'DOC', fill: '#2B579A', tone: 'info' },
273 { exts: ['csv'], label: 'CSV', fill: '#22A35A', tone: 'ok' },
274 { exts: ['json'], label: 'JSON', fill: '#64748B', tone: 'muted' },
275 { exts: ['yml', 'yaml'], label: 'YML', fill: '#64748B', tone: 'muted' },
276 { exts: ['html'], label: 'HTML', fill: '#E8743B', tone: 'accent' },
277 { exts: ['css'], label: 'CSS', fill: '#4A7BD0', tone: 'info' },
278 { exts: ['py'], label: 'PY', fill: '#3776AB', tone: 'info' },
279 { exts: ['sh'], label: 'SH', fill: '#64748B', tone: 'muted' },
280 { exts: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'], label: 'IMG', fill: '#8B5CF6', tone: 'accent' },
281 { exts: ['txt'], label: 'TXT', fill: '#64748B', tone: 'muted' },
282]
283const OTHER_FILL = '#64748B'
284
285const GLYPHS: Record<Role, string> = {
286 main: '◉',
287 recon: '◈',
288 execute: '▣',
289 verify: '✓',
290 hard: '◆',
291 reviewer: '◎',
292 other: '●',
293}
294
295const STATE_TONES: Record<DoodleState, Tone> = {
296 running: 'info',
297 done: 'ok',
298 failed: 'danger',
299 waiting: 'warn',
300 idle: 'muted',
301}
302
303const STATE_COLORS: Record<DoodleState, string> = {
304 running: TONES[STATE_TONES.running].fg,
305 done: TONES[STATE_TONES.done].fg,
306 failed: TONES[STATE_TONES.failed].fg,
307 waiting: TONES[STATE_TONES.waiting].fg,
308 idle: TONES[STATE_TONES.idle].fg,
309}
310
311// Desktop doodles (svg fills are not text colors, so they stay hex): 12×12 cells, 3 px each. B = body (by role), A = accent (by state), . = empty.
312const BODY_FILL: Record<Role, string> = {
313 main: '#6c63ff',
314 recon: '#1f9eaa',
315 execute: '#3b7ddd',
316 verify: '#2f9e55',
317 hard: '#b5523b',
318 reviewer: '#c08a1e',
319 other: '#7d8590',
320}
321
322const ACCENT_FILL: Record<DoodleState, string> = {
323 running: '#22d3ee',
324 done: '#4ade80',
325 failed: '#f87171',
326 waiting: '#facc15',
327 idle: '#e5e7eb',
328}
329
330const SPRITES: Record<Role, string[]> = {
331 // a crowned blob
332 main: [
333 '............',
334 '..A..AA..A..',
335 '..AAAAAAAA..',
336 '..BBBBBBBB..',
337 '.BBBBBBBBBB.',
338 '.BBAABBAABB.',
339 '.BBAABBAABB.',
340 '.BBBBBBBBBB.',
341 '.BBBAAAABBB.',
342 '.BBBBBBBBBB.',
343 '..BB....BB..',
344 '..BB....BB..',
345 ],
346 // a one-eyed scout drone
347 recon: [
348 '....BBBB....',
349 '..BBBBBBBB..',
350 '.BBBBBBBBBB.',
351 'BBBBAAAABBBB',
352 'BBBAAAAAABBB',
353 'BBBAA..AABBB',
354 'BBBAAAAAABBB',
355 'BBBBAAAABBBB',
356 '.BBBBBBBBBB.',
357 '..BBBBBBBB..',
358 '...B.BB.B...',
359 '..B..BB..B..',
360 ],
361 // a boxy robot with an antenna
362 execute: [
363 '.....AA.....',
364 '.....BB.....',
365 '.BBBBBBBBBB.',
366 '.BBBBBBBBBB.',
367 '.BBAABBAABB.',
368 '.BBAABBAABB.',
369 '.BBBBBBBBBB.',
370 '.BBAAAAAABB.',
371 '.BBBBBBBBBB.',
372 '...BBBBBB...',
373 '..BBB..BBB..',
374 '..BB....BB..',
375 ],
376 // a shield with a check mark
377 verify: [
378 '.BBBBBBBBBB.',
379 'BBBBBBBBBBBB',
380 'BBBBBBBBBBBB',
381 'BBBBBBBBBAAB',
382 'BBBBBBBBAABB',
383 'BAABBBBAABBB',
384 'BBAABBAABBBB',
385 '.BBAAAABBBB.',
386 '.BBBAABBBBB.',
387 '..BBBBBBBB..',
388 '...BBBBBB...',
389 '.....BB.....',
390 ],
391 // a gem golem
392 hard: [
393 '.....BB.....',
394 '....BBBB....',
395 '...BBBBBB...',
396 '..BBBBBBBB..',
397 '.BBAABBAABB.',
398 'BBBAABBAABBB',
399 'BBBBBBBBBBBB',
400 '.BBBBAABBBB.',
401 '..BBBBBBBB..',
402 '...BBBBBB...',
403 '....BBBB....',
404 '.....BB.....',
405 ],
406 // a round face with glasses
407 reviewer: [
408 '...BBBBBB...',
409 '..BBBBBBBB..',
410 '.BBBBBBBBBB.',
411 'BBBBBBBBBBBB',
412 'BAAAABBAAAAB',
413 'BA..AAAA..AB',
414 'BAAAABBAAAAB',
415 'BBBBBBBBBBBB',
416 'BBBBAAAABBBB',
417 '.BBBBBBBBBB.',
418 '..BBBBBBBB..',
419 '...BBBBBB...',
420 ],
421 // a ghost
422 other: [
423 '....BBBB....',
424 '..BBBBBBBB..',
425 '.BBBBBBBBBB.',
426 '.BBBBBBBBBB.',
427 'BBBAABBAABBB',
428 'BBBAABBAABBB',
429 'BBBBBBBBBBBB',
430 'BBBBBBBBBBBB',
431 'BBBBBBBBBBBB',
432 'BBBBBBBBBBBB',
433 'BBB.BBBB.BBB',
434 'B....BB....B',
435 ],
436}
437
438const CELL = 3
439const GRID = 12
440
441// ---------------------------------------------------------------- formatters
442
443/** '≈$0.08', '≈$1.2', '≈$12'; '$0' for zero, '<$0.01' below a cent, '—' when unknown. */
444export function money(usd: number | null | undefined): string {
445 if (typeof usd !== 'number' || !Number.isFinite(usd)) return '—'
446 const v = Math.max(0, usd)
447 if (v === 0) return '$0'
448 if (v < 0.005) return '<$0.01'
449 const cents = Math.round(v * 100) / 100
450 if (cents < 1) return '≈$' + cents.toFixed(2)
451 const dimes = Math.round(v * 10) / 10
452 if (dimes < 10) return '≈$' + dimes.toFixed(1)
453 return '≈$' + String(Math.round(v))
454}
455
456/** '0', '950', '1.5k', '37k', '1.2M', '15M'. */
457export function tokensShort(n: number): string {
458 if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return '0'
459 if (n < 1000) return String(Math.round(n))
460 const k = n / 1000
461 if (k < 10) {
462 const r = Math.round(k * 10) / 10
463 if (r < 10) return trimZero(r.toFixed(1)) + 'k'
464 }
465 if (Math.round(k) < 1000) return String(Math.round(k)) + 'k'
466 const m = n / 1_000_000
467 const r = Math.round(m * 10) / 10
468 if (r < 10) return trimZero(r.toFixed(1)) + 'M'
469 return String(Math.round(m)) + 'M'
470}
471
472/** '0:40', '10:05', '1:02:03'. */
473export function clock(ms: number): string {
474 const total = typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0
475 const h = Math.floor(total / 3600)
476 const m = Math.floor((total % 3600) / 60)
477 const s = total % 60
478 if (h > 0) return `${h}:${pad2(m)}:${pad2(s)}`
479 return `${m}:${pad2(s)}`
480}
481
482/** '4%', '100%'. Not capped at 100 (a budget can run over); negative and NaN read 0%. */
483export function pct(ratio: number): string {
484 if (typeof ratio !== 'number' || !Number.isFinite(ratio) || ratio <= 0) return '0%'
485 return String(Math.round(ratio * 100)) + '%'
486}
487
488/** '━━━━╌╌╌╌╌╌' for 0.4 at width 10; the ratio is clamped to 0..1. */
489export function barText(ratio: number, width: number = 10): string {
490 const w = typeof width === 'number' && Number.isFinite(width) && width >= 1 ? Math.floor(width) : 10
491 const filled = Math.round(clamp01(ratio) * w)
492 return '━'.repeat(filled) + '╌'.repeat(w - filled)
493}
494
495function clamp01(ratio: number): number {
496 if (typeof ratio !== 'number' || !Number.isFinite(ratio)) return 0
497 return Math.min(1, Math.max(0, ratio))
498}
499
500function pad2(n: number): string {
501 return n < 10 ? '0' + n : String(n)
502}
503
504function trimZero(s: string): string {
505 return s.endsWith('.0') ? s.slice(0, -2) : s
506}
507
508function toneOf(tone: unknown, fallback: Tone): Tone {
509 return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONES, tone) ? (tone as Tone) : fallback
510}
511
512function roleOf(role: unknown): Role {
513 return typeof role === 'string' && Object.prototype.hasOwnProperty.call(GLYPHS, role) ? (role as Role) : 'other'
514}
515
516function stateOf(state: unknown): DoodleState {
517 return typeof state === 'string' && Object.prototype.hasOwnProperty.call(STATE_COLORS, state)
518 ? (state as DoodleState)
519 : 'idle'
520}
521
522/** Drops undefined and false props: some props (Button plain, autoFocus) take only `true`. */
523function clean(props: Record<string, unknown>): Record<string, unknown> {
524 const out: Record<string, unknown> = {}
525 for (const k of Object.keys(props)) {
526 const v = props[k]
527 if (v === undefined || v === false) continue
528 out[k] = v
529 }
530 return out
531}
532
533function present(children: unknown[]): unknown[] {
534 return children.filter(c => c !== null && c !== undefined && c !== false && c !== '')
535}
536
537/** The SVG document of one doodle: a 12×12 sprite at 3 px per cell, runs merged per row. */
538export function doodleSvg(role: Role, state: DoodleState = 'idle'): string {
539 const r = roleOf(role)
540 const s = stateOf(state)
541 const fills: Record<string, string> = { B: BODY_FILL[r], A: ACCENT_FILL[s] }
542 const size = GRID * CELL
543 let rects = ''
544 SPRITES[r].forEach((line, y) => {
545 let x = 0
546 while (x < GRID) {
547 const ch = line[x] ?? '.'
548 let end = x + 1
549 while (end < GRID && line[end] === ch) end++
550 const fill = fills[ch]
551 if (fill) {
552 rects += `<rect x="${x * CELL}" y="${y * CELL}" width="${(end - x) * CELL}" height="${CELL}" fill="${fill}"/>`
553 }
554 x = end
555 }
556 })
557 return (
558 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" ` +
559 `shape-rendering="crispEdges">${rects}</svg>`
560 )
561}
562
563/** The terminal glyph of a role, as doodle() draws it and as the desktop doodle's alt text. */
564export function glyph(role: Role): string {
565 return GLYPHS[roleOf(role)]
566}
567
568// ---------------------------------------------------------------- kit v3: pure helpers and Svg
569
570/** 'ts' for 'src/a.test.TS', '' for 'Makefile', '.gitignore' or 'notes.'. Lower case, no dot. */
571export function extOf(name: string): string {
572 const base = String(name ?? '').split(/[\\/]/).pop() ?? ''
573 const dot = base.lastIndexOf('.')
574 return dot > 0 && dot < base.length - 1 ? base.slice(dot + 1).toLowerCase() : ''
575}
576
577/**
578 * A path for display: inside `root` it is relative ('.' for the root itself), inside `home` it is
579 * '~/' and the rest, anywhere else '…/' and its last two segments, so no absolute path from outside
580 * the project is drawn. A relative path is already short and comes back as given. Trailing slashes
581 * are dropped.
582 */
583export function shortPath(path: string, root: string, home: string): string {
584 const p = trimSlash(path)
585 const r = trimSlash(root)
586 const h = trimSlash(home)
587 if (!p.startsWith('/')) return p
588 if (r && p === r) return '.'
589 if (r && r !== '/' && p.startsWith(r + '/')) return p.slice(r.length + 1)
590 if (h && p === h) return '~'
591 if (h && h !== '/' && p.startsWith(h + '/')) return '~/' + p.slice(h.length + 1)
592 return '…/' + p.split('/').filter(Boolean).slice(-2).join('/')
593}
594
595function trimSlash(s: string): string {
596 const t = String(s ?? '')
597 return t.length > 1 ? t.replace(/\/+$/, '') || '/' : t
598}
599
600const SVG_CACHE = new Map<string, string>()
601const SVG_CACHE_MAX = 512
602
603/** Builds each distinct drawing once; the cache is cleared when it grows past its bound. */
604function cached(key: string, build: () => string): string {
605 const hit = SVG_CACHE.get(key)
606 if (hit !== undefined) return hit
607 if (SVG_CACHE.size >= SVG_CACHE_MAX) SVG_CACHE.clear()
608 const svg = build()
609 SVG_CACHE.set(key, svg)
610 return svg
611}
612
613function xml(s: string): string {
614 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
615}
616
617/** Dark text on a light fill (relative luminance above one half), light text otherwise. */
618function inkOn(hex: string): string {
619 const n = parseInt(hex.slice(1), 16)
620 const lin = (c: number) => {
621 const v = c / 255
622 return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
623 }
624 const l = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255)
625 return l > 0.5 ? INK_DARK : INK_LIGHT
626}
627
628function pillToneOf(tone: unknown, fallback: PillTone): PillTone {
629 return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONE_HEX, tone) ? (tone as PillTone) : fallback
630}
631
632function iconOf(name: unknown): IconName {
633 return typeof name === 'string' && Object.prototype.hasOwnProperty.call(ICONS, name) ? (name as IconName) : 'dot'
634}
635
636const FONT = 'font-family="system-ui,-apple-system,Segoe UI,sans-serif"'
637
638/** A label in a rounded rect: the shape of a pill (height 18) and of a type badge (height 16). */
639function labelSvg(text: string, fill: string, width: number, height: number, rx: number, size: number, ink = inkOn(fill)): string {
640 return (
641 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">` +
642 `<rect width="${width}" height="${height}" rx="${rx}" fill="${fill}"/>` +
643 `<text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="central" ${FONT} ` +
644 `font-size="${size}" font-weight="700" fill="${ink}">${xml(text)}</text></svg>`
645 )
646}
647
648/** A pill's height in CSS pixels; its corners are fully rounded (rx = height / 2). */
649const PILL_HEIGHT = 18
650
651/** A pill's width in CSS pixels: about 6.5 per character of 11 px bold text plus 7 each side. */
652function pillWidth(text: string): number {
653 return Math.max(PILL_HEIGHT, Math.round(text.length * 6.5 + 14))
654}
655
656/** The Svg of a pill: a fully rounded rect in the tone's CHIP_HEX fill, white text. */
657export function pillSvg(text: string, tone: Tone | 'violet' = 'muted'): string {
658 const t = pillToneOf(tone, 'muted')
659 const s = String(text)
660 return cached(`pill|${t}|${s}`, () => labelSvg(s, CHIP_HEX[t], pillWidth(s), PILL_HEIGHT, PILL_HEIGHT / 2, 11, INK_LIGHT))
661}
662
663function iconSvg(name: IconName, hex: string): string {
664 return cached(`icon|${name}|${hex}`, () =>
665 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none" ` +
666 `stroke="${hex}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">` +
667 `${ICONS[name].replace(/"S"/g, `"${hex}"`)}</svg>`,
668 )
669}
670
671function fileType(fileName: string): { label: string; fill: string; tone: Tone } | null {
672 const ext = extOf(fileName)
673 if (!ext) return null
674 const known = FILE_TYPES.find(t => t.exts.includes(ext))
675 return known ?? { label: ext.slice(0, 3).toUpperCase(), fill: OTHER_FILL, tone: 'muted' }
676}
677
678/** A type badge is 16 high and about 7 px per letter plus 8 wide. */
679function badgeWidth(label: string): number {
680 return Math.max(16, label.length * 7 + 8)
681}
682
683function badgeSvg(label: string, fill: string): string {
684 return cached(`badge|${label}|${fill}`, () => labelSvg(label, fill, badgeWidth(label), 16, 3, 10))
685}
686
687/** A track and a fill, 6 high. Without a width the markup is 100% wide and stretches to the slot. */
688function progressSvg(ratio: number, hex: string, width: number | undefined): string {
689 const full = width ?? 100
690 const fill = Math.round(clamp01(ratio) * full * 10) / 10
691 return cached(`progress|${fill}|${hex}|${width ?? ''}`, () => {
692 const size = width === undefined ? 'width="100%" height="6" preserveAspectRatio="none"' : `width="${width}" height="6"`
693 return (
694 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${full} 6" ${size}>` +
695 `<rect width="${full}" height="6" rx="3" fill="${TRACK_HEX}" opacity="0.25"/>` +
696 (fill > 0 ? `<rect width="${fill}" height="6" rx="3" fill="${hex}"/>` : '') +
697 `</svg>`
698 )
699 })
700}
701
702// ---------------------------------------------------------------- the kit
703
704const MAX_CHIPS = 4
705
706export function makeKit(ui: Elements, surface: Surface, opts: KitOptions = {}): Kit {
707 const desktop = surface === 'desktop'
708 const pillMode = opts.pill ?? PILL
709
710 const el = (type: string, props: Record<string, unknown>, children?: unknown[]): unknown => {
711 const make = ui[type]
712 const p = clean(props)
713 if (children !== undefined) p.children = present(children)
714 return make(p)
715 }
716 const T = (s: string, props: Record<string, unknown> = {}): unknown => el('Text', props, [s])
717 const color = (tone: Tone | undefined): string | undefined => (tone ? TONES[tone].fg : undefined)
718 const pieces = (part: unknown[] | string, right: boolean): unknown[] =>
719 (typeof part === 'string' ? [part] : part).map(c =>
720 typeof c === 'string' || typeof c === 'number'
721 ? T(String(c), { color: right ? QUIET : undefined, wrap: 'truncate' })
722 : c,
723 )
724 const frame = (children: unknown[], tone: Tone | undefined): unknown =>
725 el('Box', { flexDirection: 'column', borderStyle: 'round', borderColor: color(tone) ?? QUIET, paddingX: 1 }, children)
726 const chip = (text: string, tone: Tone): unknown =>
727 T('● ' + text, { color: TONES[tone].fg, bold: tone !== 'muted', wrap: 'truncate' })
728 // kit v3: Svg wherever the table has it (desktop, mobile, vscode); the terminal draws text.
729 const svgOk = surface !== 'terminal' && typeof ui.Svg === 'function'
730 const svg = (source: string, alt: string, width?: number, height?: number): unknown =>
731 el('Svg', { source, alt: alt || ' ', width, height })
732 /** The theme key of a v3 tone on the terminal: the v2 tone's color, violet 'permission'. */
733 const fg = (tone: PillTone): string => (tone === 'violet' ? PILL_FILLS.violet : TONES[tone].fg)
734
735 const kit: Kit = {
736 surface,
737
738 hero(p) {
739 const tone = p.tone ? toneOf(p.tone, 'info') : undefined
740 const title = (p.icon ? p.icon + ' ' : '') + p.title
741 return frame(
742 [
743 p.kicker ? T(p.kicker, { color: QUIET, wrap: 'truncate' }) : null,
744 T(title, { bold: true, color: color(tone), wrap: 'truncate' }),
745 p.sub ? T(p.sub, { color: QUIET, wrap: 'wrap' }) : null,
746 ],
747 tone,
748 )
749 },
750
751 card(p) {
752 const tone = p.tone ? toneOf(p.tone, 'info') : undefined
753 const head =
754 p.title || p.right
755 ? el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
756 p.title ? T(p.title, { bold: true, wrap: 'truncate' }) : null,
757 p.right ? T(p.right, { color: QUIET, wrap: 'truncate' }) : null,
758 ])
759 : null
760 return frame([head, ...(Array.isArray(p.children) ? p.children : [])], tone)
761 },
762
763 section(label, right) {
764 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
765 T(String(label), { bold: true, wrap: 'truncate' }),
766 right ? T(right, { color: QUIET, wrap: 'truncate' }) : null,
767 ])
768 },
769
770 tiles(items) {
771 return el(
772 'Box',
773 { flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, rowGap: 1 },
774 items.map(it => {
775 const tone = it.tone ? toneOf(it.tone, 'info') : undefined
776 return el(
777 'Box',
778 { flexDirection: 'column', flexGrow: 1, borderStyle: 'single', borderColor: QUIET, paddingX: 1 },
779 [
780 T(it.label, { color: QUIET, wrap: 'truncate' }),
781 T(it.value, { bold: true, color: color(tone), wrap: 'truncate' }),
782 ],
783 )
784 }),
785 )
786 },
787
788 badge(text, tone) {
789 return chip(text, toneOf(tone, 'muted'))
790 },
791
792 legend(items) {
793 const parts: unknown[] = []
794 items.forEach((it, i) => {
795 if (i > 0) parts.push(' ')
796 parts.push(T('●', { color: TONES[toneOf(it.tone, 'muted')].fg }))
797 parts.push(' ' + it.label)
798 })
799 return el('Text', { wrap: 'wrap' }, parts)
800 },
801
802 bar(ratio, opts = {}) {
803 const t = toneOf(opts.tone, 'info')
804 return el('Text', { wrap: 'truncate' }, [
805 T(barText(ratio, opts.width ?? 10), { color: TONES[t].fg }),
806 T(' ' + pct(ratio), { color: QUIET }),
807 ])
808 },
809
810 row(p) {
811 const left = pieces(p.left, false)
812 const right = p.right === undefined || p.right === '' ? [] : pieces(p.right, true)
813 const alignItems = p.align === 'center' ? 'center' : undefined
814 return el('Box', { key: p.key, flexDirection: 'row', justifyContent: 'space-between', alignItems, columnGap: 1 }, [
815 el('Box', { flexDirection: 'row', flexShrink: 1, alignItems, columnGap: 1 }, left),
816 right.length ? el('Box', { flexDirection: 'row', alignItems, columnGap: 1 }, right) : null,
817 ])
818 },
819
820 kv(label, value, tone) {
821 const t = tone ? toneOf(tone, 'info') : undefined
822 return el('Text', { wrap: 'truncate' }, [
823 T(label + ' ', { color: QUIET }),
824 T(value, { bold: true, color: color(t) }),
825 ])
826 },
827
828 buttons(items) {
829 return el(
830 'Box',
831 { flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
832 items.map(b =>
833 el('Button', {
834 key: b.key,
835 label: b.label,
836 hotkey: b.hotkey,
837 onPress: b.onPress,
838 plain: b.plain === true ? true : undefined,
839 variant: b.variant === 'primary' || b.variant === 'secondary' ? b.variant : undefined,
840 autoFocus: b.autoFocus === true ? true : undefined,
841 }),
842 ),
843 )
844 },
845
846 doodle(role, state = 'idle') {
847 const r = roleOf(role)
848 const s = stateOf(state)
849 if (desktop && typeof ui.Svg === 'function') {
850 return el('Svg', { alt: GLYPHS[r], width: GRID * CELL, height: GRID * CELL, source: doodleSvg(r, s) })
851 }
852 return T(GLYPHS[r], { color: STATE_COLORS[s], bold: s === 'running' })
853 },
854
855 stack(children, gap = 0) {
856 return el('Box', { flexDirection: 'column', rowGap: gap > 0 ? Math.floor(gap) : undefined }, children)
857 },
858
859 page(children) {
860 return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
861 },
862
863 list(children) {
864 return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
865 },
866
867 text(s, props = {}) {
868 return el('Text', props, [String(s)])
869 },
870
871 line(label, chips, right) {
872 const drawn = (Array.isArray(chips) ? chips : [])
873 .filter(c => c !== null && c !== undefined && c !== false && c !== '')
874 .slice(0, MAX_CHIPS)
875 .map(c => {
876 if (typeof c === 'string') return chip(c, 'muted')
877 if (isChipSpec(c)) return chip(c.text, toneOf(c.tone, 'muted'))
878 return c
879 })
880 const quiet = (Array.isArray(right) ? right.filter(Boolean).join(' · ') : right ?? '').trim()
881 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 2 }, [
882 el('Box', { flexDirection: 'row', flexShrink: 1, columnGap: 2 }, [
883 T(String(label), { bold: true, wrap: 'truncate' }),
884 ...drawn,
885 ]),
886 quiet ? T(quiet, { color: QUIET, wrap: 'truncate' }) : null,
887 ])
888 },
889
890 // ------------------------------------------------------------ kit v3
891
892 pill(text, tone) {
893 const t = pillToneOf(tone, 'muted')
894 const s = String(text)
895 if (!svgOk) return t === 'violet' ? T('● ' + s, { color: fg(t), bold: true, wrap: 'truncate' }) : chip(s, t)
896 if (pillMode === 'svg') return svg(pillSvg(s, t), s, pillWidth(s), PILL_HEIGHT)
897 return T(` ${s} `, { backgroundColor: PILL_FILLS[t], color: 'inverseText', bold: true, wrap: 'truncate' })
898 },
899
900 header(label, right) {
901 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
902 T(String(label).toUpperCase(), { bold: true, color: QUIET, wrap: 'truncate' }),
903 right ? T(String(right).toUpperCase(), { color: QUIET, wrap: 'truncate' }) : null,
904 ])
905 },
906
907 icon(name, tone) {
908 const n = iconOf(name)
909 const t = tone === undefined ? undefined : pillToneOf(tone, 'muted')
910 if (svgOk) return svg(iconSvg(n, t ? TONE_HEX[t] : ICON_STROKE), n, 16, 16)
911 return T(ICON_GLYPHS[n], { color: t ? fg(t) : QUIET })
912 },
913
914 typeBadge(fileName) {
915 const type = fileType(fileName)
916 if (!type) return null
917 if (svgOk) {
918 return svg(badgeSvg(type.label, type.fill), type.label, badgeWidth(type.label), 16)
919 }
920 return T(type.label, { bold: true, color: TONES[type.tone].fg })
921 },
922
923 progress(ratio, opts = {}) {
924 if (!svgOk) return kit.bar(ratio, opts)
925 const t = toneOf(opts.tone, 'info')
926 const width = typeof opts.width === 'number' && Number.isFinite(opts.width) && opts.width >= 1 ? Math.floor(opts.width) : undefined
927 // Without a width the Svg gets no width prop and markup 100% wide: the user's look confirms the
928 // desktop draws it the full width of its slot.
929 return svg(progressSvg(ratio, TONE_HEX[t], width), pct(ratio), width, 6)
930 },
931
932 nav(items, currentKey) {
933 return el(
934 'Box',
935 { key: 'nav', flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
936 (Array.isArray(items) ? items : []).map(it => {
937 if (it.key === currentKey) return T(String(it.label), { bold: true, color: TONES.accent.fg, wrap: 'truncate' })
938 if (typeof it.onPress !== 'function') return T(String(it.label), { color: QUIET, wrap: 'truncate' })
939 return el('Button', { key: it.key, label: String(it.label), hotkey: it.hotkey, onPress: it.onPress, plain: true })
940 }),
941 )
942 },
943 }
944 return kit
945}
946
947function isChipSpec(c: unknown): c is { text: string; tone?: Tone } {
948 return (
949 typeof c === 'object' &&
950 c !== null &&
951 !('type' in c) &&
952 typeof (c as { text?: unknown }).text === 'string'
953 )
954}
955hooks/pane.ts 418 lines1// pane.ts: the Agents pane of agent-map, drawn with the kit (hooks/kit.ts).
2//
3// Pure: every function takes plain values (and, for the trees, a kit made from the hook's
4// resolved elements) and returns text or an element tree. register.ts reads $.state, wires
5// the button handlers and hands them in.
6import type { AgentRecord, CapConfig, CapSources, PaneView, PendingCall } from '../types'
7import { TONES, clock, glyph, money, pct, tokensShort } from './kit'
8import type { DoodleState, Elements, Kit, Role, Tone } from './kit'
9import { CRITTER_SIZE, DEFAULT_STUCK_MS, cardOrder, critterSvg, cut, detailLines, effortLook, elapsedOf, flagOf, labelOf, markOf, returnedNote, shortModel } from './format'
10import { MAIN, totalTokens } from './model'
11import type { Agents } from './model'
12
13/** The context window ctx % divides by when the session's could not be read. */
14export const DEFAULT_WINDOW = 200_000
15
16// ---------------------------------------------------------------- roles and labels
17
18export type RoleInput = { id?: string; name?: string; description?: string; prompt?: string; model?: string }
19
20const ROLE_TAG = /\[role:\s*([a-z-]+)\s*\]/i
21
22const TAG_ROLES: Record<string, Role> = {
23 recon: 'recon',
24 explore: 'recon',
25 search: 'recon',
26 verify: 'verify',
27 validate: 'verify',
28 verifier: 'verify',
29 validator: 'verify',
30 review: 'reviewer',
31 reviewer: 'reviewer',
32 critic: 'reviewer',
33 audit: 'reviewer',
34 hard: 'hard',
35 execute: 'execute',
36 build: 'execute',
37}
38
39/**
40 * The doodle's role: the main loop is main; a `[role:x]` tag in the prompt or description wins;
41 * then the type and description words (recon|explore|search → recon, verif|valid → verify,
42 * review|critic|audit → reviewer); then an Opus model → hard; else execute.
43 */
44export function roleOf(a: RoleInput): Role {
45 if (a.id === MAIN) return 'main'
46 const tag = ROLE_TAG.exec(String(a.prompt ?? '') + ' ' + String(a.description ?? ''))?.[1]?.toLowerCase()
47 if (tag && TAG_ROLES[tag]) return TAG_ROLES[tag]
48 const words = (String(a.name ?? '') + ' ' + String(a.description ?? '')).toLowerCase()
49 if (/recon|explor|search/.test(words)) return 'recon'
50 if (/verif|valid/.test(words)) return 'verify'
51 if (/review|critic|audit/.test(words)) return 'reviewer'
52 if (/opus/.test(String(a.model ?? '').toLowerCase())) return 'hard'
53 return 'execute'
54}
55
56const FAMILIES = ['opus', 'sonnet', 'haiku', 'fable']
57const capital = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
58
59/** `claude-opus-5-5` → `Opus 5.5`, `claude-sonnet-4-20250514` → `Sonnet 4`, `haiku` → `Haiku`, '' → `?`; another id as given (24 characters at most). */
60export function modelLabel(model: string): string {
61 const m = String(model || '').toLowerCase().replace(/\./g, '-')
62 const hit = /(opus|sonnet|haiku|fable)-(\d{1,2})(?:-(\d{1,2}))?(?!\d)/.exec(m)
63 if (hit) return capital(hit[1]) + ' ' + hit[2] + (hit[3] ? '.' + hit[3] : '')
64 const s = shortModel(model)
65 if (FAMILIES.includes(s)) return capital(s)
66 return s === '?' ? s : cut(String(model).trim().replace(/^claude-/, ''), 24)
67}
68
69// ---------------------------------------------------------------- status
70
71export type CardStatus = 'running' | 'waiting' | 'done' | 'failed' | 'stopped' | 'aborted'
72
73/** Running, unless a tool call waits on the person; otherwise the record's end state. */
74export function cardStatus(a: AgentRecord): CardStatus {
75 if (a.status === 'running') return a.waiting?.length ? 'waiting' : 'running'
76 return a.status
77}
78
79/** ● running and ✓ done (success) · ◷ waiting (claude) · ✗ failed (error) · ■ stopped · ○ aborted (inactive). */
80export const MARKS: Record<CardStatus, { glyph: string; tone: Tone }> = {
81 running: { glyph: '●', tone: 'ok' },
82 waiting: { glyph: '◷', tone: 'accent' },
83 done: { glyph: '✓', tone: 'ok' },
84 failed: { glyph: '✗', tone: 'danger' },
85 stopped: { glyph: '■', tone: 'muted' },
86 aborted: { glyph: '○', tone: 'muted' },
87}
88
89const DOODLE_STATE: Record<CardStatus, DoodleState> = {
90 running: 'running',
91 waiting: 'waiting',
92 done: 'done',
93 failed: 'failed',
94 stopped: 'idle',
95 aborted: 'idle',
96}
97
98// ---------------------------------------------------------------- figures
99
100/** The agent's context fill: its latest request's input side over the session's window. An approximation: a subagent's own window isn't exposed. */
101export function ctxRatio(a: AgentRecord, window: number): number {
102 const w = typeof window === 'number' && Number.isFinite(window) && window > 0 ? window : DEFAULT_WINDOW
103 return Math.max(0, a.lastRequestTokens ?? 0) / w
104}
105
106/** The agent's cost: what its requests added up to; $0 before any token, null when its tokens came from a model the price table doesn't know. */
107export function agentCost(a: AgentRecord): number | null {
108 if (typeof a.costUsd === 'number' && Number.isFinite(a.costUsd)) return a.costUsd
109 return totalTokens(a.tokens) === 0 ? 0 : null
110}
111
112/** `tools 12 · last: Bash npm test`. */
113export function toolsText(a: AgentRecord): string {
114 const last = a.activity ? ' · last: ' + a.activity.replace(/^([^:\s]+): /, '$1 ') : ''
115 return 'tools ' + a.toolCalls + last
116}
117
118/** The card's step: `4/5 · Signup` from its TodoWrite list, else `12 tools`. */
119export function stepText(a: AgentRecord): string {
120 const t = a.todo
121 if (t && t.total > 0) return t.done + '/' + t.total + (t.current ? ' · ' + t.current : '')
122 return a.toolCalls + ' tools'
123}
124
125/** `4/5 · Signing up` from its TodoWrite list, else `tools 12 · last: Bash npm test`. */
126export function progressText(a: AgentRecord): string {
127 const t = a.todo
128 return t && t.total > 0 ? stepText(a) : toolsText(a)
129}
130
131/** `ctx 4% · 41k ≈$0.08 0:40`. */
132export function statsText(a: AgentRecord, now: number, window: number): string {
133 return 'ctx ' + pct(ctxRatio(a, window)) + ' · ' + tokensShort(totalTokens(a.tokens)) + ' ' + money(agentCost(a)) + ' ' + clock(elapsedOf(a, now))
134}
135
136/** `Opus 5.5 · low`: the model, then the effort as given when one was seen. */
137export function modelLine(a: AgentRecord): string {
138 return modelLabel(a.model) + (a.effort ? ' · ' + a.effort : '')
139}
140
141/** The card's bar: todo items done over all, else full once done, else its elapsed time over the
142 * longest sibling's (a failed card keeps the time it ran). */
143export function barRatio(a: AgentRecord, now: number, longestMs: number): number {
144 const t = a.todo
145 if (t && t.total > 0) return Math.min(1, t.done / t.total)
146 if (a.status === 'done') return 1
147 return longestMs > 0 ? Math.min(1, elapsedOf(a, now) / longestMs) : 0
148}
149
150export type Totals = { costUsd: number | null; tokens: number; timeMs: number; agents: number }
151
152/**
153 * The header tiles over the session's subagents. Cost and tokens are sums. Time is wall-clock
154 * time during which at least one subagent ran: the union of each agent's span, from its first
155 * start to its end (now while it runs), so parallel agents count once.
156 */
157export function sessionTotals(agents: Agents, now: number): Totals {
158 let cost: number | null = null
159 let tokens = 0
160 let n = 0
161 const spans: [number, number][] = []
162 for (const a of Object.values(agents)) {
163 if (a.id === MAIN || !a.isSpawned) continue
164 n++
165 tokens += totalTokens(a.tokens)
166 const c = agentCost(a)
167 if (c !== null) cost = (cost ?? 0) + c
168 const start = a.startedAt
169 const end = a.status === 'running' ? now : (a.endedAt ?? start + (a.durationMs ?? 0))
170 spans.push([start, Math.max(start, end)])
171 }
172 spans.sort((x, y) => x[0] - y[0])
173 let timeMs = 0
174 let open: [number, number] | null = null
175 for (const s of spans) {
176 if (open && s[0] <= open[1]) open[1] = Math.max(open[1], s[1])
177 else {
178 if (open) timeMs += open[1] - open[0]
179 open = [s[0], s[1]]
180 }
181 }
182 if (open) timeMs += open[1] - open[0]
183 return { costUsd: n === 0 ? 0 : cost, tokens, timeMs, agents: n }
184}
185
186// ---------------------------------------------------------------- trees
187
188export type PaneHandlers = {
189 toggle: (id: string) => unknown
190 stop: (id: string) => unknown
191 collapse: () => unknown
192 view: (v: PaneView) => unknown
193}
194
195export type HeaderOptions = {
196 view: PaneView
197 collapsed: boolean
198 on: Pick<PaneHandlers, 'collapse' | 'view'>
199 /** The main loop's line, drawn under the buttons (the cards view; the tree views draw it with their tree). */
200 main?: unknown
201}
202
203/**
204 * The header block: Cost · Tokens · Time tiles, then Collapse/Expand (cards only) and the view
205 * buttons (the shown view's button is primary), then the main loop's line when given, one row apart.
206 */
207export function headerTree(kit: Kit, agents: Agents, now: number, o: HeaderOptions): unknown {
208 const t = sessionTotals(agents, now)
209 const viewButton = (v: PaneView, label: string) => ({
210 key: 'view-' + v,
211 label,
212 variant: o.view === v ? ('primary' as const) : undefined,
213 onPress: () => o.on.view(v),
214 })
215 return kit.stack(
216 [
217 kit.tiles([
218 { label: 'Cost', value: money(t.costUsd) },
219 { label: 'Tokens', value: tokensShort(t.tokens) },
220 { label: 'Time', value: clock(t.timeMs) },
221 ]),
222 kit.buttons([
223 ...(o.view === 'cards' ? [{ key: 'collapse', label: o.collapsed ? 'Expand' : 'Collapse', onPress: () => o.on.collapse() }] : []),
224 viewButton('cards', 'Cards'),
225 viewButton('tree', 'Tree'),
226 viewButton('team', 'Team'),
227 ]),
228 o.main,
229 ],
230 1,
231 )
232}
233
234export type CardsOptions = {
235 /** The hook's resolved elements: each card sits in a Box keyed `card-<id>` and `columns` wide. */
236 ui: Elements
237 columns: number
238 /** The session's context window, for ctx %. */
239 window: number
240 pending: readonly PendingCall[]
241 /** Agents whose details are open. */
242 expanded: readonly string[]
243 collapsed: boolean
244 /** The agent whose Stop button is armed. */
245 armed: string | null
246 stuckAfterMs?: number
247 on: PaneHandlers
248}
249
250/** The critter on a surface with Svg; the role glyph on the terminal. */
251function avatar(kit: Kit, ui: Elements, role: Role, status: CardStatus | 'idle', effort: string, size: number): unknown {
252 const state = status === 'idle' ? 'idle' : DOODLE_STATE[status]
253 if (kit.surface === 'terminal' || typeof ui.Svg !== 'function') {
254 // The role glyph in the status's tone (kit.doodle would paint waiting in the warning key)
255 const tone = status === 'idle' ? 'muted' : MARKS[status].tone
256 return kit.text(glyph(role), { color: TONES[tone].fg, bold: status === 'running' })
257 }
258 const source = critterSvg({
259 main: role === 'main',
260 hat: effortLook(effort).hat,
261 state,
262 faded: status === 'stopped' || status === 'aborted',
263 size,
264 })
265 return ui.Svg({ source, alt: role + ' ' + (status === 'idle' ? 'planned' : status), width: size, height: size })
266}
267
268function card(kit: Kit, agents: Agents, a: AgentRecord, now: number, longestMs: number, o: CardsOptions): unknown {
269 const status = cardStatus(a)
270 const mark = MARKS[status]
271 const running = a.status === 'running'
272 const open = o.expanded.includes(a.id)
273 const effort = effortLook(a.effort)
274 const quiet = (s: string) => kit.text(s, { color: 'inactive', wrap: 'truncate-end' })
275
276 const right: unknown[] = [kit.text(mark.glyph, { color: TONES[mark.tone].fg, bold: true })]
277 if (running) {
278 right.push(kit.buttons([{ key: 'stop-' + a.id, label: o.armed === a.id ? 'Confirm stop' : 'Stop', plain: true, onPress: () => o.on.stop(a.id) }]))
279 }
280 const title = kit.row({
281 left: [
282 kit.buttons([{ key: 'row-' + a.id, label: open ? '▾' : '▸', plain: true, onPress: () => o.on.toggle(a.id) }]),
283 kit.text(markOf(a) + labelOf(a), { bold: true, wrap: 'truncate-end' }),
284 ],
285 right,
286 })
287 const model = kit.row({
288 left: [effort.word ? kit.text(effort.word, { color: TONES[effort.tone].fg, bold: true }) : null, quiet(modelLine(a))].filter(Boolean),
289 })
290 const step = kit.row({ left: [kit.text(stepText(a), { wrap: 'truncate-end' })], right: statsText(a, now, o.window) })
291 // An Svg bar takes the column's width; the terminal's text bar as much as it leaves (frame 4, glyph 2, percent 5)
292 const svgOk = kit.surface !== 'terminal' && typeof o.ui.Svg === 'function'
293 const bar = o.collapsed
294 ? null
295 : kit.progress(barRatio(a, now, longestMs), svgOk ? { tone: effort.tone } : { width: Math.max(10, o.columns - 11), tone: effort.tone })
296 const flag = o.collapsed ? undefined : flagOf(a, now, o.stuckAfterMs ?? DEFAULT_STUCK_MS)
297 const ret = returnedNote(agents, a)
298 const column = o.ui.Box({
299 flexDirection: 'column',
300 flexGrow: 1,
301 flexShrink: 1,
302 children: [
303 title,
304 model,
305 step,
306 bar,
307 flag ? kit.text(flag.text, { color: flag.color ?? 'inactive', wrap: 'truncate-end' }) : null,
308 open ? quiet(toolsText(a)) : null,
309 open && ret ? quiet(ret) : null,
310 // Then the full record, wrapped to the column (frame 4, avatar glyph and gap 2)
311 ...(open ? detailLines(agents, a, now, Math.max(12, o.columns - 6)).map(quiet) : []),
312 ].filter(Boolean),
313 })
314 const body = o.ui.Box({ flexDirection: 'row', columnGap: 1, children: [avatar(kit, o.ui, roleOf(a), status, a.effort, CRITTER_SIZE), column] })
315 return o.ui.Box({
316 key: 'card-' + a.id,
317 flexDirection: 'column',
318 width: o.columns,
319 children: [kit.card({ tone: running ? 'info' : undefined, children: [body] })],
320 })
321}
322
323/** The main loop's line: its critter, tokens, tool calls and any call waiting on the person. */
324export function mainRow(kit: Kit, agents: Agents, ui: Elements): unknown {
325 const m = agents[MAIN]
326 const waits = m?.waiting?.length ? Array.from(new Set(m.waiting.map(w => w.tool))).join(', ') : ''
327 return kit.row({
328 key: 'main',
329 left: [
330 avatar(kit, ui, 'main', waits ? 'waiting' : 'running', m?.effort ?? '', ROW_CRITTER),
331 kit.text('main (orchestrator) · ' + tokensShort(totalTokens(m?.tokens)) + ' tok · ' + (m?.toolCalls ?? 0) + ' tools', { bold: true, wrap: 'truncate-end' }),
332 ],
333 right: waits ? [kit.text('◷ waiting: ' + waits, { color: TONES[MARKS.waiting.tone].fg, wrap: 'truncate-end' })] : undefined,
334 })
335}
336
337/** The critter's size on a one-line row (the main loop, a planned spawn). */
338const ROW_CRITTER = 24
339
340/**
341 * The cards view's body, under the header block: the `Agents · n` section with one card per
342 * subagent (running first, then waiting on the person, then finished; one row between cards) and
343 * the `Planned · n` section of Agent calls whose spawn hasn't started, one row between sections.
344 */
345export function cardsTree(kit: Kit, agents: Agents, order: readonly string[], now: number, o: CardsOptions): unknown {
346 const ids = cardOrder(agents, order)
347 const running = ids.filter(id => agents[id].status === 'running').length
348 const longestMs = Math.max(0, ...ids.map(id => elapsedOf(agents[id], now)))
349 const sections: unknown[] = []
350 if (ids.length > 0) {
351 sections.push(
352 kit.stack([
353 kit.header('Agents · ' + ids.length, running + ' running'),
354 kit.list(ids.map(id => card(kit, agents, agents[id], now, longestMs, o))),
355 ]),
356 )
357 } else if (o.pending.length === 0) {
358 sections.push(kit.text('no subagents yet', { color: 'inactive' }))
359 }
360 if (o.pending.length > 0) {
361 const parts: unknown[] = [kit.header('Planned · ' + o.pending.length)]
362 o.pending.forEach((p, i) => {
363 const right = [p.subagentType, p.model ? modelLabel(p.model) : '', 'asked ' + clock(now - p.at) + ' ago'].filter(Boolean).join(' · ')
364 const role = roleOf({ name: p.subagentType, description: p.description, prompt: p.prompt, model: p.model })
365 parts.push(
366 kit.row({
367 key: 'planned-' + (p.toolUseId || String(i)),
368 left: [avatar(kit, o.ui, role, 'idle', '', ROW_CRITTER), kit.text(p.description || p.subagentType || 'agent', { wrap: 'truncate-end' })],
369 right,
370 }),
371 )
372 })
373 sections.push(kit.stack(parts))
374 }
375 return kit.stack(sections, 1)
376}
377
378// ---------------------------------------------------------------- caps
379
380/** How to change the caps, under the Caps block. */
381export const CAPS_HINT = 'Change in /config (lk-agent-map rows); /agent-map cap <kind> <n> saves an override, /agent-map cap reset clears it.'
382
383/** The Caps block's rows, in the order the settings list them. */
384const CAP_ROWS: readonly { key: keyof CapConfig; label: string }[] = [
385 { key: 'maxConcurrent', label: 'Max running at once' },
386 { key: 'maxPerTurn', label: 'Max spawns per turn' },
387 { key: 'maxPerSession', label: 'Max spawns per session' },
388 { key: 'warnAt', label: 'Warn at' },
389 { key: 'maxReturnedTokens', label: 'Max result tokens in main' },
390]
391
392/** One cap's value: `4`, `75%`, `150k tok`; `no cap` for 0. */
393export function capValue(caps: CapConfig, key: keyof CapConfig): string {
394 if (key === 'warnAt') return Math.round(caps.warnAt * 100) + '%'
395 const n = caps[key]
396 if (!(n > 0)) return 'no cap'
397 return key === 'maxReturnedTokens' ? tokensShort(n) + ' tok' : String(n)
398}
399
400/**
401 * The Caps block: an upper-cased header with where the values came from, one row per cap
402 * (label; value and source on the right) and the hint that says how to change them.
403 */
404export function capsTree(kit: Kit, caps: CapConfig, sources: CapSources): unknown {
405 const from = Array.from(new Set(CAP_ROWS.map(r => sources[r.key]).filter(s => s !== 'default')))
406 return kit.stack([
407 kit.header('Caps', from.length ? 'from ' + from.join(', ') : 'defaults'),
408 ...CAP_ROWS.map(r =>
409 kit.row({
410 key: 'cap-' + r.key,
411 left: [kit.text(r.label, { wrap: 'truncate-end' })],
412 right: [kit.text(capValue(caps, r.key), { bold: true, wrap: 'truncate' }), sources[r.key]],
413 }),
414 ),
415 kit.text(CAPS_HINT, { color: 'inactive', wrap: 'wrap' }),
416 ])
417}
418hooks/prices.ts 83 lines1// Model prices for agent-map's cost estimates. Pure: nothing here touches `$`.
2import type { PriceRow, TokenCounts } from '../types'
3
4// USD per million tokens, list prices as of 2026-10 (partly confirmed; override with "prices" in ~/.claude/agent-map.json): [family, input, output, cacheRead, cacheWrite1h]
5export const PRICES: Array<[string, number, number, number, number]> = [
6 ['fable-5-1', 10, 50, 1.0, 20],
7 ['opus-5-5', 4, 20, 0.2, 8],
8 ['opus-5', 5, 25, 0.5, 10],
9 ['opus-4', 5, 25, 0.5, 10],
10 ['sonnet-5-5', 2, 10, 0.2, 4],
11 ['sonnet-5', 2, 10, 0.2, 4],
12 ['sonnet', 3, 15, 0.3, 6],
13 ['haiku-5-5', 1, 5, 0.1, 2],
14 ['haiku', 1, 5, 0.1, 2],
15]
16
17export const PRICES_DATE = '2026-10'
18
19export type Price = { family: string; input: number; output: number; cacheRead: number; cacheWrite: number }
20
21/** A model id as the table matches it: lower case, dots as dashes (`claude-opus-4.5` reads as `claude-opus-4-5`). */
22function normal(model: string): string {
23 return String(model || '').toLowerCase().replace(/\./g, '-')
24}
25
26/** The price of the first family the model id contains, longest family names first; null when none matches. */
27export function priceOf(model: string, table: readonly PriceRow[] = PRICES): Price | null {
28 const id = normal(model)
29 if (!id) return null
30 const rows = [...table].sort((a, b) => b[0].length - a[0].length)
31 for (const [family, input, output, cacheRead, cacheWrite] of rows) {
32 if (family && id.includes(family)) return { family, input, output, cacheRead, cacheWrite }
33 }
34 return null
35}
36
37const n = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0)
38
39/** input×in + output×out + cacheRead×read + cacheCreate×write, per million tokens; null for a model the table doesn't know. */
40export function costUsd(tokens: TokenCounts | undefined, model: string, table: readonly PriceRow[] = PRICES): number | null {
41 const p = priceOf(model, table)
42 if (!p) return null
43 const t = tokens ?? { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 }
44 return (n(t.input) * p.input + n(t.output) * p.output + n(t.cacheRead) * p.cacheRead + n(t.cacheCreate) * p.cacheWrite) / 1_000_000
45}
46
47const okPrice = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0
48
49/** One override row: `[in, out, cacheRead, cacheWrite]` or `{ input, output, cacheRead, cacheWrite }`. */
50function rowOf(family: string, v: unknown): PriceRow | undefined {
51 if (Array.isArray(v)) {
52 const [i, o, r, w] = v
53 return v.length === 4 && okPrice(i) && okPrice(o) && okPrice(r) && okPrice(w) ? [family, i, o, r, w] : undefined
54 }
55 if (v && typeof v === 'object') {
56 const x = v as Record<string, unknown>
57 return okPrice(x.input) && okPrice(x.output) && okPrice(x.cacheRead) && okPrice(x.cacheWrite)
58 ? [family, x.input, x.output, x.cacheRead, x.cacheWrite]
59 : undefined
60 }
61 return undefined
62}
63
64/**
65 * Lays the valid rows of a config file's `prices` object over `base`, by family:
66 * `{ "opus-5-5": [4, 20, 0.2, 8], "my-model": { "input": 1, "output": 5, "cacheRead": 0.1, "cacheWrite": 2 } }`.
67 * A family the table lacks is added; anything malformed is ignored.
68 */
69export function mergePrices(base: readonly PriceRow[], over: unknown): PriceRow[] {
70 const out: PriceRow[] = base.map(r => [...r] as PriceRow)
71 if (!over || typeof over !== 'object' || Array.isArray(over)) return out
72 for (const [rawFamily, v] of Object.entries(over as Record<string, unknown>)) {
73 const family = normal(rawFamily).trim()
74 if (!family) continue
75 const row = rowOf(family, v)
76 if (!row) continue
77 const i = out.findIndex(r => r[0] === family)
78 if (i >= 0) out[i] = row
79 else out.push(row)
80 }
81 return out
82}
83types/index.d.ts 176 lines1export type AgentStatus = 'running' | 'done' | 'failed' | 'aborted' | 'stopped'
2
3export type TokenCounts = {
4 input: number
5 output: number
6 cacheRead: number
7 cacheCreate: number
8}
9
10/** One message edge: `to` on the sender's record, `from` on the receiver's. */
11export type MessageEdge = { to?: string; from?: string; at: number }
12
13export type AgentRecord = {
14 id: string
15 /** The agent type (subagent_type), such as general-purpose or Explore. */
16 name: string
17 description: string
18 prompt: string
19 model: string
20 /** The agentId of the loop that spawned it, or "main". */
21 parent: string
22 isTeammate: boolean
23 /** A fork of its parent (agent.spawn e.fork): it inherits the parent's context. */
24 isFork: boolean
25 isBackground: boolean
26 /** False for a loop seen only through tool.call or turn.step (an engine fork). */
27 isSpawned: boolean
28 startedAt: number
29 /** When the current run began; a resumed agent starts a new run. */
30 runStartedAt: number
31 endedAt?: number
32 /** Summed duration of finished runs. */
33 durationMs?: number
34 status: AgentStatus
35 requests: number
36 toolCalls: number
37 toolsByName: Record<string, number>
38 lastTool: string
39 /** The last thing it did, such as `Bash: npm test` (tool and the first 40 characters of its input). */
40 activity: string
41 /** The last tool.call or turn.step in this loop, for the idle alert. */
42 lastActiveAt: number
43 /** Tool calls of this loop that tool.check put to the person ('ask'), until the call runs. */
44 waiting: WaitingCall[]
45 /** The idle toast went out for the current stall. */
46 stuckNotified: boolean
47 /** Who stopped it, when status is 'stopped'. */
48 stoppedBy?: string
49 /** TaskStop could not stop it: its further tool calls are refused. */
50 muted: boolean
51 tokens: TokenCounts
52 messages: MessageEdge[]
53 /** Estimated tokens (characters / 4) of the results this agent returned to its caller's context. */
54 returnedTokens?: number
55 /** The effort of its latest request (turn.step e.effort), else the spawn's, else ''. */
56 effort: string
57 /** input + cache read + cache write of its latest request: what its context held then. */
58 lastRequestTokens: number
59 /** Progress from its latest TodoWrite call; null when it never wrote a list (or cleared it). */
60 todo: TodoProgress | null
61 /** Estimated cost in USD from each request's tokens and model (hooks/prices.ts); null while no request had a known model. */
62 costUsd: number | null
63}
64
65/** A TodoWrite list in short: completed items, all items, and the in_progress item's activeForm or content. */
66export type TodoProgress = { done: number; total: number; current: string }
67
68/** USD per million tokens: [family, input, output, cacheRead, cacheWrite (1 h)]. */
69export type PriceRow = [string, number, number, number, number]
70
71/** The spawn caps and warning thresholds, merged from defaults, the plugin's settings, files and /agent-map cap. */
72export type CapConfig = {
73 /** Running subagents at once (forks and teammates included); 0 turns it off. */
74 maxConcurrent: number
75 /** Spawns in one main-loop turn (forks and teammates not counted); 0 turns it off. */
76 maxPerTurn: number
77 /** Spawns in the session; 0 turns it off. */
78 maxPerSession: number
79 /** Share of a cap (0..1) at which the band turns yellow and a toast goes out. */
80 warnAt: number
81 /** Tokens of subagent results in the main context before the warning; 0 turns it off. */
82 maxReturnedTokens: number
83 /** How long a permission 'ask' stays unresolved before the agent shows ⏸ waiting; 0 marks at once. */
84 waitGraceMs: number
85}
86
87/** Where a cap's value came from, last layer first: the defaults, the plugin's settings (/config), ~/.claude/agent-map.json,
88 * <cwd>/.claude/agent-map.json, or what /agent-map cap saved. */
89export type CapSource = 'default' | 'settings' | 'user file' | 'project file' | 'saved (/agent-map cap)'
90
91/** The source of each cap's value. */
92export type CapSources = Record<keyof CapConfig, CapSource>
93
94export type CapKind = 'concurrent' | 'turn' | 'session'
95
96/** Spawn counters for the cap checks and the band. */
97export type CapCounters = {
98 /** Spawns this main-loop turn, forks and teammates left out. */
99 turn: number
100 session: number
101 /** A spawn was denied this turn: the band turns red. */
102 deniedThisTurn: boolean
103 /** The near-cap toast went out this turn. */
104 warnedThisTurn: boolean
105 /** The person allowed spawns past the caps for the rest of this turn. */
106 overrideTurn: boolean
107 /** Estimated tokens of subagent results returned to the main context this session. */
108 returnedTotal: number
109 /** The returned-tokens toast went out. */
110 returnedWarned: boolean
111}
112
113export type LogKind =
114 | 'spawned'
115 | 'denied'
116 | 'override'
117 | 'first-tool'
118 | 'waiting'
119 | 'resumed'
120 | 'idle'
121 | 'stopped'
122 | 'muted'
123 | 'done'
124 | 'failed'
125 | 'aborted'
126 | 'returned'
127
128/** One lifecycle event of one agent. */
129export type LogEntry = { at: number; id: string; label: string; kind: LogKind; text: string }
130
131export type PaneView = 'tree' | 'cards' | 'team'
132
133export type WaitingCall = { toolUseId: string; tool: string; at: number }
134
135/** An Agent tool call waiting for its agent.spawn. */
136export type PendingCall = {
137 toolUseId: string
138 caller: string
139 description: string
140 subagentType: string
141 model: string
142 prompt: string
143 isBackground: boolean
144 at: number
145}
146
147declare module 'claude-code' {
148 interface PluginState {
149 'lk-agent-map': {
150 agents: Record<string, AgentRecord>
151 order: string[]
152 pending: PendingCall[]
153 expanded: string[]
154 now: number
155 band: boolean
156 everSpawned: boolean
157 /** The agent whose Stop button is armed (pressed once), or null. */
158 armed: string | null
159 caps: CapConfig
160 /** Where each cap's value came from, for the pane's Caps block. */
161 capSources: CapSources
162 counters: CapCounters
163 log: LogEntry[]
164 /** The view the person chose; null until they choose one (the pane then draws cards, /agent-map text the tree). */
165 view: PaneView | null
166 showLog: boolean
167 /** The cards view draws one line per agent. */
168 collapsed: boolean
169 /** The session's context window in tokens, for the ctx % approximation. */
170 window: number
171 /** The price table after the config files' `prices` overrides. */
172 prices: PriceRow[]
173 }
174 }
175}
176