SLOPSHOPPER

loopkeeper Agent Map

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…

newpanebandguardcommandtoast
★ 1v0.6.3MITupdated 2026-10-14connectwithub/loopkeeper/plugins/lk-agent-map
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lk-agent-map
│ ┃ Agent map ✕ › fix the failing auth test and add an audit log call │ ┃ ┌──────────────────────────────────────────┐ │ ┃ │ Cost │ ⏺ Read(src/auth.ts) │ ┃ │ $0 │ ⎿ Read 6 lines │ ┃ └──────────────────────────────────────────┘ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ Collapse ] [ Cards ] [ Tree ] [ Team ] ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ◉ main (orchestrator) · 0 tok · 9 tools │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ no subagents yet │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ CAPS DEFAULTS │ ┃ Max running at once 4 default › /agent-map │ ┃ Max spawns per turn 8 default │ ┃ Max spawns per session 40 default │ ┃ Warn at 75% default │ ┃ Max result tokens in main 150k tok default │ ┃ Change in /config (lk-agent-map rows); │ ┃ /agent-map cap <kind> <n> saves an override, │ ┃ /agent-map cap reset clears it. │ ┃ │ ┃ [ Clear finished ] [ Log ] [ Close ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent map
┌──────────────────────────────────────────────────────────┐ │ Cost │ │ $0 │ └──────────────────────────────────────────────────────────┘ [ Collapse ] [ Cards ] [ Tree ] [ Team ] ◉ main (orchestrator) · 0 tok · 9 tools no subagents yet CAPS DEFAULTS Max running at once 4 default Max spawns per turn 8 default Max spawns per session 40 default Warn at 75% default Max result tokens in main 150k tok default Change in /config (lk-agent-map rows); /agent-map cap <kind> <n> saves an override, /agent-map cap reset clears it. [ Clear finished ] [ Log ] [ Close ]
README

lk-agent-map

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

  • You run orchestrated work that spawns many subagents and want to see what each one is doing and what it costs.
  • You want a ceiling on how many agents run at once, per turn or per session, so a spawn loop can't run away.
  • Subagent results fill the main context and you want a warning while there is still room to /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

Commands

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

What it shows

  • Pane (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.
  • Stop: every running row ends with a Stop button. The first press arms it (the label turns to Confirm stop) and it disarms by itself after 5 seconds. A second press within those 5 seconds calls Claude Code's own 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.
  • Mute (the fallback): when 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.
  • Last activity: under each running agent, a dim ⋯ 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 })).
  • Waiting on you: when Claude Code's permission check answers 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.
  • Idle alert: a running agent with no tool call and no model request for 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.
  • Forks and teammates: ⑂ 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.
  • Agents the spawn hook missed: on each one-second tick while anything runs, on /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.
  • Band (one line above the prompt): 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.
  • Message edges: a dim → msg to <label> line under the sender, for SendMessage traffic between main and a subagent or between subagents.
  • main: the orchestrator's own tokens and tool calls are on the header line, so you can compare its share with the agents'.

Tokens are input + output + cache read + cache write, summed from each model request (turn.step). The band total counts subagents only.

The Agents cards

  • Header tiles over the session's subagents: Cost (the sum of the agents' cost estimates), Tokens (their sum) and Time: wall-clock time during which at least one subagent ran, the union of each agent's span from its first start to its end (now while it runs), so agents running side by side count once. Tiles are single-border boxes, label over a bold value, with no fill. Collapse folds each card to its first three lines (no bar or notes); Expand unfolds them. Then Cards, Tree and Team: the shown view's button is the primary one.
  • One card per agent, running first, then waiting on you, then finished, in a round frame (the suggestion color while it runs, quiet otherwise). On the left the avatar; on the right four lines:
  • ▸, 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.
  • The effort word in its color, then the model it actually ran on and the effort as seen: 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.
  • Progress from the agent's own 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.
  • A full-width bar in the effort's color: the TodoWrite share done, else the agent's elapsed time over the longest card's. Waits, idle and mute notes and 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.

  • Planned · n: Agent calls whose subagent hasn't started yet (waiting on a permission answer or the cap override ask), with type, model and how long ago they were asked. A call the cap gate or the engine refused leaves the list.

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

Spawn caps

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.

  • Near a cap: once a spawn brings any cap to 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.
  • At a cap: the spawn is refused, and the model reads: 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.
  • Override: a spawn whose description or prompt carries [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.
  • Every decision (spawned with its counts, denied with the cap, override answers) goes to the lifecycle log.

Context cost of results

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.

Views and the log

  • Cards (/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].
  • Tree (/agent-map tree): the map above.
  • Team (/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.
  • The choice is saved in $.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.
  • Lifecycle log: spawned (with cap counts), denied (with the cap), override answers, first tool, waiting, resumed, idle, stopped or muted, returned (result tokens), and done, failed or aborted with duration and tokens. /agent-map log prints the last 40.

Configuration

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

Install

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

Footprint

What claude plugin validate --strict . reports for this plugin (Claude Code 2.1.294), and what that means:

AreaWhat the plugin has
Hooks moduleOne: ./register.ts, in hooks/hooks.json.
Hook eventssession.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).
Refusalsagent.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).
State17 atoms under lk-agent-map.
Storeband, caps, view2.
EnvReads HOME; writes nothing.

What it runs:

  • Processes: none.
  • Files: reads ~/.claude/agent-map.json and <project>/.claude/agent-map.json; writes nothing.
  • Model calls: none.
  • Tool calls: TaskStop, only when you press a row's Stop twice (arm, then confirm), with your press as the consent.
  • Network: none.

Notes for directory reviewers

The directory's validator listed these items for this plugin. Each bullet says what the code does and where (hooks/register.ts unless named).

  • The one tool it calls (policy holds 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 no
Source 7 files
hooks/register.ts 1299 lines
1// 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 lines
1// 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}
825
hooks/format.ts 657 lines
1// 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}
657
hooks/kit.ts 955 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
955
hooks/pane.ts 418 lines
1// 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}
418
hooks/prices.ts 83 lines
1// 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}
83
types/index.d.ts 176 lines
1export 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