SLOPSHOPPER

named-subagents-mod

Gives every subagent a themed name in the task tree, and lists the running agents under those names in a side pane: what each is doing, its tokens, its…

newpanebandguardcommandtoast
v0.3.0MITupdated 2026-10-07Bobby-cell-commits/named-subagents-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · named-subagents-mod
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ ✻ Agents │ ┃ No agents yet. They show here while they r ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ╷ › /names │ ┃ │ ⎿ named-subagents-mod: 395 names in 14 pools (395 built in). │ ┃ ▸ ⎿ named-subagents-mod: Every agent draws from: the pool that fits │ ┃ │ ⎿ named-subagents-mod: Your file: /Users/dev/.claude/named-suba │ ┃ ╵ ⎿ named-subagents-mod: Project file: /work/app/.claude/named-subag │ ┃ ⎿ named-subagents-mod: Pools: explore 30 · code 33 · research 32 · │ ┃ ⎿ named-subagents-mod: │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩
Pane · Agents
✻ Agents No agents yet. They show here while they run. ╷ │ ▸ │ ╵
README

named-subagents-mod

CI

Fan out several subagents in Claude Code and the task tree shows each one by its type. This mod gives every subagent its own themed name instead (Turing, Magellan, Holmes), drawn from a 395-name registry and never shared by two live agents. It also lists the running agents under those names in a pane beside the conversation: what each one is doing, its tokens and its clock, with its whole conversation a press away (see The agents pane).

<img src="https://raw.githubusercontent.com/Bobby-cell-commits/named-subagents-mod/master/assets/demo.gif" alt="A Claude Code 2.1.292 session in the fullscreen layout, 150 columns: four subagents fan out and the agents pane opens beside the transcript, listing them as Basquiat, Eames, PaulRand and Mucha with what each is reading, its tool count, tokens and clock; the task tree under the prompt shows the same four names; after /names set pirates and /names use pirates the next three are Bonny, Kidd and Teach, and the pane ends on a receipt for all seven">

The GIF is a real session, not a mock-up: scripts/capture_demo.sh drives Claude Code in tmux and saves the screen twice a second, and scripts/render_tree_gif.py draws the whole screen for some of those frames, holding the ones with something to read. Two things are taken out: the banner is blanked and the home directory in a path reads ~. It was recorded on 0.3.0 in a 150-column terminal in the fullscreen layout (/tui fullscreen), where the pane docks beside the transcript and opens by itself. In a terminal under 144 columns you get a line under the prompt counting the running agents and a toast as each finishes, and /roster opens the pane.

It is a Claude Code mod (function hooks), so it needs Claude Code 2.1.287 or later and nothing else: no Python, no Node at runtime. It replaces the retired named-subagents Python plugin (last release 0.7.2).

The name is for you to read: the agent is not told its name (the engine does not put a mod-set name into the subagent's context), so there is no SubagentStart queue, ledger or file lock, and no burst-start pairing to get wrong.

Install

Type this at the prompt of a terminal session:

/plugin install named-subagents-mod --marketplace Bobby-cell-commits/named-subagents-mod

Answer y to add the marketplace, pick a scope (user = every session), and set the options or keep their defaults. The options screen is where you can type your own names straight away (see Your own names). The mod is active from then on; no reload is needed.

From a shell instead:

claude plugin marketplace add Bobby-cell-commits/named-subagents-mod
claude plugin install named-subagents-mod@named-subagents-mod --scope user

Your own names

Three ways in, all feeding the same pool. Use whichever is nearest to hand.

1. The options screen (at install, or later with /plugin configure named-subagents-mod or /config). Type names into Your names, separated by commas: Ripley, Deckard, Mary Shelley. They join every built-in pool and get about half of the draws while one of them is free, so even two names show up often. Turn on Use only my names to leave the built-in names out.

2. The /names command, in any session. It keeps your names in one file and applies to the next agent dispatched:

/names                             what is set, and where the files are
/names add Ripley "Mary Shelley"   more names, for every built-in pool
/names remove Sisyphus             never draw this name
/names rename Turing Alan          call one name something else
/names set pirates Kidd Bonny      define a set (a pool of your own); same name again replaces it
/names unset pirates               drop a set
/names use pirates | auto          draw every agent from one pool, or go back to picking by task
/names only on | off               leave out the built-in names
/names import <file>               merge a names file, a JSON list, or one name per line
/names reset                       clear your file (a .bak copy is kept)

import joins what it reads with what you have: lists are added to, and a set of the same name gets the new names as well.

3. A names file, for whole sets, dotfiles and sharing: ~/.claude/named-subagents.json (under CLAUDE_CONFIG_DIR when that is set). A project can carry its own at .claude/named-subagents.json, layered on top of yours. Every key is optional:

{
  "only": false,
  "use": "pirates",
  "names": ["Ripley", "Deckard"],
  "remove": ["Sisyphus"],
  "rename": { "Turing": "Alan" },
  "sets": {
    "pirates": { "names": ["Blackbeard", "Bonny", "Kidd"], "for": ["Explore"], "keywords": ["search", "find"] },
    "code": { "names": ["Neo", "Trinity"], "replace": true }
  }
}
KeyMeaning
namesExtra names. They join every built-in pool, not the sets you define, and get about half of the draws while one of them is free (so do names a set adds to a built-in pool).
renameOld name to new name.
removeNames never drawn. Applied after rename, so a renamed name is hidden by its new name.
setsA set is a pool of your own. for lists the agent types that draw from it and wins unless one pool is pinned (use, or the Name pool option); keywords match the task description. A set named like a built-in pool (code, explore, …) adds to that pool, or with "replace": true takes its place. "pirates": ["Kidd", "Bonny"] is short for a set with just names.
useOne pool for every agent, whatever its type. Wins over the Name pool option; auto or absent leaves the choice to that option, which picks by type and task unless you pinned a pool there.
onlytrue leaves out everything before this file, the built-in names included.

Order: built-in names, your file, the project's file, then the options screen's names. examples/pirates.json is a set to try, from a checkout of this repository: /names import examples/pirates.json.

The name rule. Claude Code accepts an agent name made of letters, digits, _ and -, starting with a letter or digit, up to 64 characters, and refuses the whole dispatch for anything else. So names are cleaned on the way in: Mary Shelley becomes MaryShelley, Zoë becomes Zoe, O'Brien becomes OBrien. A name with nothing usable in it (名前, an emoji) is skipped and reported.

Limits. A names file is read as untrusted input, since a project's arrives with the repository. A file over 256 KB is not read. A list holds up to 2,000 names, a file up to 100 sets, and a set up to 200 for or keywords entries; what is over is left out and reported. Anything quoted from a file in a warning or in /names output has its control characters replaced, so a file cannot send escape sequences to your terminal. A name holding a control character is skipped.

When a file is wrong. A file that is not valid JSON, a name that cannot be used, or an unknown key raises a toast and a status line once per change of the file; everything else in the file still applies, and agents are always named. /names lists the problems, and will not edit a file it cannot read in full, because rewriting it would drop the unread part; /names reset starts clean and keeps a .bak.

The agents pane

A roster of the session's subagents, one row an agent, in columns: its mark (spinning while it runs), its name, its task, the tool call it is running now or how it ended, its tool count, tokens and clock. From a live session (three agents, Claude Code 2.1.292):

✻ Agents · Haiku 4.5                                                             3 running

· DaVinci  Read retry.txt and write design es…  Read(retry.txt)    1 tool    25.1k     5s
✳ Hokusai  Read cache.txt and write design es…  Read(cache.txt)    1 tool    25.2k     4s
✽ Mucha    Read parser.txt and write design e…  Read(parser.txt)   1 tool    25.2k     3s

── timeline ──────────────────────────────────────────────────────────────────────────────
✻ DaVinci  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━    5s
✻ Hokusai  ············━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━    4s
✻ Mucha    ························━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━    3s

The names are the ones in Claude Code's own task tree: both read the engine's agent list. An agent the model named itself is listed under that name; one with no name (naming turned off) reads Type(description).

  • Where it shows. In the fullscreen layout (/tui fullscreen) it docks beside the transcript. On the main screen it is a summary of up to eight rows above the prompt, in the same columns. It opens by itself when an agent starts, in a terminal at least 144 columns wide; narrower than that, /roster opens it, and until then a line under the prompt counts the running agents (✻ 2 of 4 agents running).
  • A conversation. Press an agent's name or task: the pane widens and shows its brief, its replies and its tool calls with their results. b goes back, k and j step through it, Esc gives the keyboard back.
  • Folding. Ten seconds after the last agent finishes the pane folds to a tab above the prompt (◂ Agents ✓ 3); press the tab to bring it back, and a new agent brings it back by itself. The ▸ handle on the pane's left edge folds it by hand. /roster opens or closes it.
  • Also there. A model shared by every agent is said once, in the header. An agent's own subagents sit indented under it. A timeline puts the latest batch on one time axis. A finished batch gets a receipt (✓ 3 done in 8s · 21s of agent time (2.6× in parallel) · 3 tool uses · 158k tokens) and each finish a toast. ■ Stop, then Confirm stop, ends a running agent through Claude Code's own TaskStop. A background agent between turns counts as done; a teammate between turns reads waiting.
  • What it reaches. It watches: the agent list, each agent's tool calls (name and a short argument), its token usage, and its transcript while you have it open. Stop is its one action. It makes no network request, runs no process and keeps nothing after the session.

The pane is agentpane 1.1.4 by Anji Xu (MIT), brought into this mod with a name column, the roster layout and a few wording changes. See Credits.

What it reaches, and what it trusts

Everything the mod does goes through Claude Code's plugin API; it runs no process and makes no network request. What it reads and writes:

  • Reads: the session's agent list; every Agent dispatch (to set its name); each agent's tool calls (the tool's name and a short argument), its token usage and the model it runs on; an agent's transcript, only while you have its conversation open in the pane; HOME, USERPROFILE and CLAUDE_CONFIG_DIR; your names file and the project's; and a file you name to /names import.
  • Writes: your names file (~/.claude/named-subagents.json) and its .bak, on /names edits only. Nothing else, and nothing is kept after the session beyond that file.
  • One action: TaskStop on the agent whose conversation you have open, after you press Stop and then Confirm stop. The consent it gives the engine names the agent's type and id, never text a model wrote.

What it draws is not all yours. Agent names and descriptions are written by the model; tool arguments and results and the agents' replies come from whatever the agent read, a web page or a repository included; and a project's names file arrives with the repository. So all of it is treated as untrusted text: control characters, escape sequences and the characters that reorder text are removed before anything is drawn or quoted, every line is cut to length, a names file is bounded in size and in how many names, sets and routes it may hold, and a name must pass the Agent tool's own rule. An agent's replies are drawn as Markdown, as Claude Code draws its own transcript, so text an agent read can shape what its conversation shows you (a heading, a link, a line that looks like a button); it cannot press anything, run anything or reach the network, and a link in a reply is not pressable. The pane's buttons are its own elements, not text.

To check this without trusting the README, clone the repository and run claude plugin validate .: its calls: line lists every engine method the mod can call. For 0.3.0 that is $.agent.list, $.clock, $.command.register, $.env.get, $.fs.{exists,read,stat,write}, $.session.{messages,root,surfaces}, $.state, $.tool.call and $.ui.*; no $.http, $.process, $.model or $.mcp. A review of 0.3.0 for exploitable paths found none; its notes are in the changelog.

What it does

  • tool.call{Agent}: if the call has no name, draws one and calls next({ ...e, name }). The description is left as is. A model-supplied name passes through untouched.
  • Pool: the theme option pins one; auto matches subagent_type first (generic roles like general-purpose go by description keywords first), then the default pool. The pool is the built-in one with your names layered on; both names files are checked on every dispatch.
  • Uniqueness: skips every name in $.agent.list() and every in-flight draw. An exhausted pool spills to the default pool, then to any free name, then to Name-2, Name-3, …
  • agent.spawn: after the spawn, checks the name reached it and that $.agent.list() shows it on the new agent id. Anything else raises a toast and a status line (named-subagents: drew X but …). Neither hook can block a dispatch: both carry a .catch that continues the call.
  • The pane: agent.spawn lists an agent the moment it starts, a once-a-second read of $.agent.list() keeps the list current, tool.call records what each agent is doing, turn.step its tokens and model, and two ui.render hooks draw the pane and the summary above the prompt. Every one of them passes its event on unchanged.
  • One status line for both. Naming's alarm stands on it until the next clean spawn (a names file that is wrong, until it is fixed); with agents running and the pane off screen the count leads and the alarm follows: ✻ 2 agents running · named-subagents: your names file: not valid JSON ….
  • A failure in the pane's part of a shared hook does not skip naming's part, and naming's "naming failed" alarm is raised only when the name did not reach the call.

Options (userConfig)

Set at install, later with /plugin configure named-subagents-mod or /config (type the option's title to find it; a change applies at once), or in ~/.claude/settings.json under pluginConfigs, keyed by the plugin's id: named-subagents-mod@named-subagents-mod installed from the marketplace, named-subagents-mod@inline loaded with --plugin-dir. A project's .claude/settings.local.json did not carry them (Claude Code 2.1.292); --settings <file> does.

FieldDefaultMeaning
themeautoauto, or a category key (code, explore, debug, …) to always use that pool
enabledtruefalse turns naming off without uninstalling (and /names with it)
namesemptyYour names, separated by commas; they join every built-in pool
only_customfalsetrue leaves the built-in names out
panetruefalse turns the pane off: names only (see below)
autoOpentrueopen the pane when an agent starts; off, /roster opens it
foldAfter10seconds after the last agent finishes before the pane folds; 0 keeps it open
motiontrueanimate the spinner; off, it stands still and the clocks still run
toaststruea toast when agents finish
keepFinished8finished agents the list keeps (1–30)
statusLinetruecount the running agents under the prompt while the pane is not on screen

The first four are naming's. pane (Show the agents pane on the options screen) is the pane's one switch, and the six after it (Pane: …) adjust the pane while it is on. To have names without the pane, turn pane off: nothing opens, nothing is counted under the prompt, no finish toast and no tab appear, the agent list is not read every second and /roster is not offered. Naming, /names and the check of each spawned agent's name work as before. Turned off mid-session, an open pane closes and its list is forgotten; /roster stays in the command list until the session ends (a session cannot take a command back) and answers that the pane is switched off. enabled turns off naming only: the pane then lists agents by type.

Layout

  • hooks/index.ts: the hooks module hooks.json names; it registers the two halves.
  • hooks/names.ts: naming's hooks. hooks/draw.ts: pure draw logic. hooks/custom.ts: pure custom-names logic (the name rule, the names file, layering, /names edits). hooks/pool.ts: generated.
  • hooks/pane.tsx: the pane's hooks and drawing, and the two hooks both halves share (session.start, agent.spawn: a plugin hooks an event once). hooks/live.tsx, hooks/lanes.tsx: the spinner, clocks and timeline, drawn on the terminal's own frame clock. hooks/time.ts: pure time helpers. hooks/status.ts: the one status line the halves share. types/index.d.ts: the pane's $.state contract.
  • registry.json: the name pool (14 categories, 395 names), the source of hooks/pool.ts.
  • scripts/gen_pool.mjs: regenerates hooks/pool.ts from registry.json; --check exits 1 when it is stale.
  • scripts/capture_demo.sh, scripts/render_tree_gif.py: record and draw assets/demo.gif (tmux and Python with Pillow; not needed to use the mod). assets/demo-frames/ is the recording the GIF was drawn from, so python3 scripts/render_tree_gif.py assets/demo-frames assets/demo.gif redraws it.
  • spec/: draw and custom-names logic tests (plain node). tests/: hook tests (the engine's test kit): names.test.ts, pane.test.tsx and pane-logic.test.ts (the pane's own), and together.test.tsx (naming and the pane in one plugin).
  • examples/: a names file to import. probes/: the live proofs and the TUI capture driver.

Checks

node scripts/gen_pool.mjs --check   # pool matches the registry
node --test spec/*.spec.ts          # draw and custom-names logic, plain node 22.18+ (62 tests)
claude plugin test .                # hooks against the engine's test kit (150 tests)
claude plugin validate .

CI runs the two node checks; the two claude checks need a local Claude Code.

Develop

claude --plugin-dir . loads the checkout for one session. To run every session from a checkout, add the folder as a marketplace (edits apply after /reload-plugins):

claude plugin marketplace add /path/to/named-subagents-mod
claude plugin install named-subagents-mod@named-subagents-mod --scope user

Headless proof and TUI evidence: probes/mods-names-proof/ (naming), probes/custom-names-probe/ (custom names) and probes/pane-fold/ (one plugin for both: what the engine allows, and the live captures).

Known limits

  • The name shows in the task tree and in $.agent.list(); the transcript's launch list and finish notices quote the description only.
  • The name is display-only: a subagent cannot say its own name, and nothing records which name ran which task after the session ends.
  • A set smaller than the number of live agents runs out: the next draw comes from the default pool, then from any free name, then gets a number (Ripley-2).
  • /names edits your file only, and only while the whole file reads cleanly: one unknown key or bad entry and it asks you to fix the file by hand first. A project's names file is edited by hand. A project file can rename or hide names for anyone who opens that project (names only, within the name rule).
  • Overlapping draws (two dispatches in flight at once) are covered by the kit test only. In live runs the engine started same-message dispatches 0.5–2 s apart.
  • With the retired 0.7.2 Python plugin also enabled, the mod's name wins, but the Python hooks still run (see probes/mods-names-proof/README.md). Uninstall the Python plugin.
  • The pane opens by itself only in a terminal at least 144 columns wide (Claude Code's rule for a pane nobody asked for). Below that you get the line under the prompt and the toasts, and /roster.
  • A row has no token count in a pane under 88 columns and no tool count under 72; both are in the agent's conversation header.
  • The pane's start times are when it first saw an agent, and its tokens count from when the mod loaded.
  • Naming's alarm and the pane's finish notice share the plugin's one toast: the newer replaces the older. The alarm also stays on the status line.
  • A reload of the mod (an option changed in /config, /reload-plugins) forgets a standing alarm. A names file that is still wrong is alarmed again at the next dispatch; a "drew X but…" alarm is not.
  • If /roster cannot be registered the mod says so in a toast, and the pane still opens by itself and counts agents.
  • With the separate agentpane plugin also installed there are two panes with the same id. Uninstall it: claude plugin uninstall agentpane@claude-agentpane.
  • Not tried live: the pane in the desktop app, VS Code and mobile with named agents (the test kit draws them); a teammate's waiting row.
  • On the options screen a number option (fold after, finished agents listed) reads blank until you set it; the default (10, 8) applies all the same.
  • The mods API is new (Claude Code 2.1.287, 2026-10-01) and a release may change what this mod relies on; tested on 2.1.291 and 2.1.292 only. Both install paths above were tried on 2.1.292 from a clean CLAUDE_CONFIG_DIR.

Credits

The agents pane is agentpane by Anji Xu, version 1.1.4, used under the MIT licence. Its notice is kept in LICENSE-agentpane and covers hooks/pane.tsx, hooks/live.tsx, hooks/lanes.tsx, hooks/time.ts, types/index.d.ts, tests/pane.test.tsx and tests/pane-logic.test.ts, which carry this mod's changes on top.

The pane here is a copy, so agentpane's later fixes arrive only by hand. It was taken at upstream commit 17be889 (1.1.4). To bring a newer one in:

git clone https://github.com/xuanji86/claude-agentpane && cd claude-agentpane
git diff 17be889 <new commit> -- hooks/ types/

Apply that diff by hand to the files listed above (hooks/hooks.json there is not used here; a new or changed option is in .claude-plugin/plugin.json, so diff that file too). Three have other names there: hooks/register.tsx is hooks/pane.tsx here, hooks/pane.test.tsx is tests/pane.test.tsx and hooks/agentpane.test.ts is tests/pane-logic.test.ts. Then run the checks under Checks, and write the new commit here and at the top of hooks/pane.tsx. The command is /roster here and /agentpane there.

License

MIT. See LICENSE; the pane's files are under LICENSE-agentpane, also MIT.

Source 11 files
hooks/index.ts 18 lines
1// The plugin's hooks module. hooks.json takes one path, so this file is it and the two
2// halves each keep their own: names.ts (every Agent dispatch gets a name) and pane.tsx (the
3// agents pane, which lists each agent under that name).
4//
5// Registrations nest in order, first outermost, so naming's tool.call{Agent} hook runs
6// around the pane's tool.call hook. Nothing depends on that order today: the pane takes an
7// agent's name from agent.spawn and the agent list, not from the call. session.start and
8// agent.spawn are hooked once, in pane.tsx, for both halves (see the top of names.ts).
9
10import type { Register } from 'claude-code';
11import { register as names } from './names.ts';
12import { register as pane } from './pane.tsx';
13
14export const register: Register = (on, options) => {
15  names(on, options);
16  pane(on, options);
17};
18
hooks/names.ts 309 lines
1// named-subagents mod: every Agent dispatch without a model-supplied `name` gets one from
2// the themed pool, so the task tree labels it. The name is display-only: the engine does
3// not put it in the subagent's context, so there is no ledger, queue or file lock.
4//
5// tool.call{Agent}  draws a name not held by a listed agent or an in-flight draw, and
6//                   sets it on the call; the description is left as is.
7// agent.spawn       checks the name reached the spawn and that $.agent.list() shows it
8//                   on the new id; anything else raises a toast.
9// command.run       /names shows and edits the user's names file.
10//
11// A plugin hooks an event once (without a matcher), and the pane hooks session.start and
12// agent.spawn too. So those two hooks are in pane.tsx, and this file's part of each is a
13// function the pane's hook calls: NAMES_COMMAND and checkSpawn. `$` does not cross a file,
14// so checkSpawn is handed what it may do with the engine (`Say`, and the list read).
15//
16// The pool is the built-in one with the user's changes layered on: the names file in the
17// config directory, then the project's, then the install screen's `names` list. Both files
18// are checked on every dispatch (exists, then stat), so an edit applies to the next agent.
19
20import type { AgentInfo, Register, EngineInterface, PluginOptions } from 'claude-code';
21import { pickName, type Pool } from './draw.ts';
22import { POOL } from './pool.ts';
23import {
24  LIMITS, NAME_RE, USAGE, applyCustom, editCustom, emptyCustom, mergeCustom, parseCustom, parseImport, printable,
25  readCustom, serializeCustom, tokenize, type Custom, type Layer, type Parsed,
26} from './custom.ts';
27import { withAlarm } from './status.ts';
28
29const TAG = 'named-subagents';
30const FILE = 'named-subagents.json';
31
32// Drawn but not yet settled. JS runs the draw and the reserve with no await between them,
33// so parallel dispatches in one message cannot draw the same name.
34const reserved = new Set<string>();
35// tool_use_id -> the name this module set, read back by agent.spawn to verify.
36const drawn = new Map<string, string>();
37
38function rand(): number {
39  const a = new Uint32Array(1);
40  crypto.getRandomValues(a);
41  return (a[0] ?? 0) / 2 ** 32;
42}
43
44/** What an alarm does with the engine: a toast, and the plugin's status line. */
45export type Say = { toast: (line: string) => unknown; status: (line: string | undefined) => unknown };
46
47/** Raises an alarm through `say`: for the hooks in pane.tsx, which cannot pass `$` here. */
48export function raise(say: Say, text: string): void {
49  // Toast plus status line: the toast fades, the status line stays until the next clean spawn.
50  const one = printable(text).replace(/\s+/g, ' '); // may quote a names file: one printable line
51  const line = `${TAG}: ${one.length > 240 ? `${one.slice(0, 240)}…` : one}`;
52  void Promise.resolve(say.toast(line)).catch(() => undefined); // audit-allow: fail-loud — the alarm itself has nowhere louder to report
53  void Promise.resolve(say.status(withAlarm(line))).catch(() => undefined); // audit-allow: fail-loud — same
54}
55
56function alarm($: EngineInterface, text: string): void {
57  raise({ toast: line => $.ui.toast(line, { timeoutMs: 10000 }), status: line => $.ui.status(line) }, text);
58}
59
60type Paths = { user?: string; project?: string };
61type FileState = { path?: string; sig: string; parsed?: Parsed; error?: string };
62type Loaded = { pool: Pool; use?: string; problems: string[]; user: FileState; project: FileState };
63const NO_FILE: FileState = { sig: 'none' }; // one object, so "nothing changed" holds for a path that does not exist
64
65let paths: Paths | undefined;
66let loaded: Loaded | undefined;
67let loading: Promise<Loaded> | undefined;
68let loadFailed = false; // the load itself broke: alarmed once, not on every dispatch
69
70async function findPaths($: EngineInterface): Promise<Paths> {
71  if (paths) return paths;
72  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'));
73  const cfg = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home}/.claude` : undefined);
74  const root = await $.session.root();
75  paths = { user: cfg ? `${cfg}/${FILE}` : undefined, project: root ? `${root}/.claude/${FILE}` : undefined };
76  if (paths.user === paths.project) paths.project = undefined; // a session started in the home directory
77  return paths;
78}
79
80/** The file's signature (`none` when absent) and, when it changed, its parsed content. */
81async function readFile($: EngineInterface, path: string | undefined, label: string, prev?: FileState): Promise<FileState> {
82  if (path === undefined) return NO_FILE;
83  let sig = 'none';
84  try {
85    let size = 0;
86    if (await $.fs.exists(path)) {
87      const st = await $.fs.stat(path);
88      sig = `${st.mtimeMs}:${st.size}`;
89      size = st.size;
90    }
91    if (prev && prev.sig === sig) return prev;
92    if (sig === 'none') return { path, sig };
93    // Checked before the read: a project's file is not ours, and nothing that large is a names file.
94    if (size > LIMITS.fileChars) return { path, sig, error: `${label}: ${path} is too large (over ${LIMITS.fileChars / 1024} KB) and was not read` };
95    return { path, sig, parsed: parseCustom(await $.fs.read(path), label) };
96  } catch {
97    // There, but not readable. Signed by what was seen, so the alarm is raised once, not per dispatch.
98    return prev?.sig === `unreadable:${sig}` ? prev : { path, sig: `unreadable:${sig}`, error: `${label}: cannot read ${path}` };
99  }
100}
101
102/** The install screen's `names` and `only_custom`, as the layers around the files. */
103function optionLayers(options: PluginOptions): { first: Layer[]; last: Layer[]; problems: string[] } {
104  const p = readCustom({ names: options.names ?? [] }, 'install-screen names');
105  const first = options.only_custom === true ? [{ label: 'install screen', custom: { ...emptyCustom(), only: true } }] : [];
106  return { first, last: p.custom.names.length ? [{ label: 'install screen', custom: p.custom }] : [], problems: p.problems };
107}
108
109/**
110 * The pool to draw from now. Re-reads a names file only when it changed; a problem in one
111 * (bad JSON, a name the Agent tool would refuse) is alarmed once per change and the rest of
112 * the file still applies. Never throws: with nothing readable, the built-in pool.
113 * `quiet` is for /names, whose own output lists the problems.
114 */
115function load($: EngineInterface, options: PluginOptions, quiet = false): Promise<Loaded> {
116  // Dispatches in one message share one read (and one alarm).
117  return (loading ??= read($, options, quiet).finally(() => { loading = undefined; }));
118}
119
120async function read($: EngineInterface, options: PluginOptions, quiet: boolean): Promise<Loaded> {
121  try {
122    const at = await findPaths($);
123    const user = await readFile($, at.user, 'your names file', loaded?.user);
124    const project = await readFile($, at.project, "the project's names file", loaded?.project);
125    if (loaded && loaded.user === user && loaded.project === project) return loaded;
126    const opt = optionLayers(options);
127    const layers: Layer[] = [
128      ...opt.first,
129      ...(user.parsed ? [{ label: 'your names file', custom: user.parsed.custom }] : []),
130      ...(project.parsed ? [{ label: "the project's names file", custom: project.parsed.custom }] : []),
131      ...opt.last,
132    ];
133    const applied = applyCustom(POOL, layers);
134    const problems = [
135      ...[user, project].flatMap(f => [...(f.error ? [f.error] : []), ...(f.parsed?.problems ?? [])]),
136      ...opt.problems, ...applied.problems,
137    ];
138    if (problems.length && !quiet) alarm($, `${problems[0]}${problems.length > 1 ? ` (+${problems.length - 1} more: /names)` : ''}`);
139    loaded = { pool: applied.pool, use: applied.use, problems, user, project };
140    loadFailed = false;
141    return loaded;
142  } catch (err) {
143    if (!loadFailed) alarm($, `could not load your names (${String(err).slice(0, 80)}); using the built-in names`);
144    loadFailed = true;
145    return { pool: POOL, problems: [String(err).slice(0, 200)], user: NO_FILE, project: NO_FILE };
146  }
147}
148
149const count = (pool: Pool) => new Set(pool.categories.flatMap(c => c.names.map(n => n.toLowerCase()))).size;
150
151/** A list for /names: its first 50 entries, and how many more there are. */
152const some = (list: string[]) => `${list.slice(0, 50).join(', ')}${list.length > 50 ? ` (+${list.length - 50} more)` : ''}`;
153
154function describe(l: Loaded, at: Paths, options: PluginOptions, theme: string): string {
155  const file = (f: FileState, path?: string) => (path === undefined ? 'no location' : `${path} (${f.sig === 'none' ? 'not there yet' : f.error ? 'not read' : 'found'})`);
156  const mine = (c?: Custom) => (c ? [
157    ...(c.only ? ['    only: the names before this file are left out'] : []),
158    ...(c.names.length ? [`    names: ${some(c.names)}`] : []),
159    ...(c.remove.length ? [`    never drawn: ${some(c.remove)}`] : []),
160    ...(Object.keys(c.rename).length ? [`    renamed: ${some(Object.entries(c.rename).map(([f, t]) => `${f} -> ${t}`))}`] : []),
161    ...Object.entries(c.sets).slice(0, 20).map(([k, s]) => `    set ${k} (${s.names.length}): ${some(s.names)}${s.for.length ? `; for ${some(s.for)}` : ''}${s.keywords.length ? `; keywords ${some(s.keywords)}` : ''}${s.replace ? '; replaces the built-in names' : ''}`),
162    ...(Object.keys(c.sets).length > 20 ? [`    (+${Object.keys(c.sets).length - 20} more sets)`] : []),
163    ...(c.use !== undefined ? [`    use: ${c.use}`] : []),
164  ] : []);
165  const opt = optionLayers(options);
166  const pin = l.use ?? (theme !== 'auto' ? theme : undefined);
167  return [
168    `${count(l.pool)} names in ${l.pool.categories.length} pools (${count(POOL)} built in).`,
169    `Every agent draws from: ${pin ?? 'the pool that fits its type and task'}.`,
170    `Your file:    ${file(l.user, at.user)}`, ...mine(l.user.parsed?.custom),
171    `Project file: ${file(l.project, at.project)}`, ...mine(l.project.parsed?.custom),
172    ...(opt.first.length || opt.last.length ? [`Install screen: ${opt.first.length ? 'only my names; ' : ''}${some(opt.last[0]?.custom.names ?? [])}`] : []),
173    `Pools: ${l.pool.categories.map(c => `${c.key} ${c.names.length}`).join(' · ')}`,
174    ...(l.problems.length ? ['Problems:', ...l.problems.map(p => `    ${p}`)] : []),
175    ...[l.user.parsed, l.project.parsed].flatMap(p => (p?.cleaned.length ? [`Adjusted to fit the name rule: ${p.cleaned.join(', ')}`] : [])),
176    '', USAGE,
177  ].join('\n');
178}
179
180/** `/names …`: every verb but the bare listing rewrites the user's file. */
181async function names($: EngineInterface, args: string, options: PluginOptions, theme: string): Promise<string> {
182  const [verb = 'list', ...argv] = tokenize(args);
183  const at = await findPaths($);
184  const now = await load($, options, true);
185  if (verb === 'list') return describe(now, at, options, theme);
186  if (verb === 'help') return USAGE;
187  if (at.user === undefined) return 'No HOME or CLAUDE_CONFIG_DIR is set, so there is nowhere to keep your names.';
188  const before = now.user.parsed?.custom ?? emptyCustom();
189  let next: Custom;
190  let lines: string[];
191  if (verb === 'reset') { // the one verb that works on a file /names cannot read
192    if (now.user.sig === 'none') return `Nothing to reset; ${at.user} does not exist.`;
193    const text = await $.fs.read(at.user);
194    if (text.trim() === serializeCustom(emptyCustom()).trim()) return `Nothing to reset; ${at.user} is already empty.`;
195    await $.fs.write(`${at.user}.bak`, text);
196    next = emptyCustom();
197    lines = [`Cleared your names. The previous file is at ${at.user}.bak.`];
198  } else if (now.user.error || now.user.parsed?.problems.length) {
199    // Rewriting the file would drop whatever could not be read from it, so it is left alone.
200    return [
201      `${at.user} was left alone, because /names cannot read all of it and would drop the rest:`,
202      ...(now.user.error ? [`    ${now.user.error}`] : []), ...(now.user.parsed?.problems ?? []).map(p => `    ${p}`),
203      'Fix it by hand, or run /names reset to start clean (a .bak copy is kept).',
204    ].join('\n');
205  } else if (verb === 'import') {
206    const [src] = argv;
207    if (src === undefined) return 'Usage: /names import <file>';
208    const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'));
209    const path = src.startsWith('~/') && home ? `${home}/${src.slice(2)}` : src;
210    let text: string;
211    try {
212      if ((await $.fs.stat(path)).size > LIMITS.fileChars) return `${path} is too large to be a names file (over ${LIMITS.fileChars / 1024} KB); nothing was imported.`;
213      text = await $.fs.read(path);
214    } catch { return `Cannot read ${path}.`; }
215    const p = parseImport(text, src);
216    const pools = [...Object.keys(before.sets), ...Object.keys(p.custom.sets), ...POOL.categories.map(c => c.key)];
217    const badUse = p.custom.use !== undefined && !pools.some(k => k.toLowerCase() === p.custom.use?.toLowerCase()) ? p.custom.use : undefined;
218    if (badUse !== undefined) delete p.custom.use;
219    next = mergeCustom(before, p.custom);
220    const sets = Object.keys(p.custom.sets);
221    lines = [
222      `Imported ${src}: ${p.custom.names.length} names${sets.length ? `, sets ${sets.join(', ')}` : ''}.`,
223      ...(p.cleaned.length ? [`Adjusted to fit the name rule: ${p.cleaned.join(', ')}`] : []),
224      ...(badUse !== undefined ? [`Left out its "use": there is no pool called ${badUse}.`] : []),
225      ...p.problems,
226    ];
227    if (serializeCustom(next) === serializeCustom(before)) return [...lines, 'Nothing new; your file is unchanged.'].join('\n');
228  } else {
229    const edit = editCustom(before, verb, argv, POOL);
230    if (!edit.changed) return edit.lines.join('\n');
231    next = edit.custom;
232    lines = edit.lines;
233    if (verb === 'use' && next.use === undefined && theme !== 'auto') lines.push(`The Name pool option still pins ${theme}; set it to auto in /config to pick by agent type and task.`);
234  }
235  await $.fs.write(at.user, serializeCustom(next));
236  loaded = undefined; // the next dispatch (and the line below) reads the file as written
237  const after = await load($, options, true);
238  return [...lines, `${count(after.pool)} names in ${after.pool.categories.length} pools now. Saved to ${at.user}.`, ...after.problems].join('\n');
239}
240
241/** Whether naming is on (the `enabled` option). Off, this file hooks nothing and /names is not offered. */
242export const namingOn = (options: PluginOptions) => options.enabled !== false;
243
244/** /names, registered by the pane's session.start hook. */
245export const NAMES_COMMAND = { name: 'names', description: 'Show or change the names your subagents get', argumentHint: '[add|remove|rename|set|unset|use|only|import|reset]' };
246
247/**
248 * agent.spawn's check, called by the pane's hook once the spawn has answered: the name drawn
249 * for this tool call reached the spawn, and the engine's list shows it on the new id.
250 * Anything else is alarmed; a clean spawn clears the last alarm. Never throws.
251 */
252export async function checkSpawn(
253  say: Say, list: () => PromiseLike<readonly AgentInfo[]>, e: { tool_use_id: string; name?: string }, agentId: string | undefined,
254): Promise<void> {
255  const want = drawn.get(e.tool_use_id);
256  if (want === undefined || agentId === undefined) return; // not ours, or not started
257  try {
258    if (e.name !== want) {
259      raise(say, `drew ${want} but the spawn got ${e.name ?? 'no name'}`);
260    } else {
261      const row = (await list()).find(a => a.id === agentId);
262      if (row?.name !== want) raise(say, `drew ${want} but the agent list shows ${row?.name ?? (row ? 'no name' : 'no row')}`);
263      else if (!loaded?.problems.length) void Promise.resolve(say.status(withAlarm(undefined))).catch(() => undefined); // audit-allow: fail-loud — clearing a stale alarm line
264    }
265  } catch {
266    raise(say, `could not verify ${want}`);
267  }
268}
269
270export const register: Register = (on, options) => {
271  if (!namingOn(options)) return;
272  const theme = typeof options.theme === 'string' ? options.theme : 'auto';
273
274  on('command.run', { command: 'names' }, async ($, e) => {
275    try {
276      return { text: printable(await names($, e.args, options, theme)) }; // it quotes names files and typed paths
277    } catch (err) {
278      return { text: printable(`/names failed (${String(err).slice(0, 200)}). Your file was not changed unless a line above says so.`) };
279    }
280  });
281
282  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
283    if (e.name !== undefined && e.name !== '') return next(e); // the model named it: leave it
284    const { pool, use } = await load($, options);
285    const live = (await $.agent.list()).map(a => a.name).filter((n): n is string => !!n);
286    const { name } = pickName(pool, {
287      subagentType: e.subagent_type, description: e.description, theme: use ?? theme,
288      taken: [...live, ...reserved], rand,
289    });
290    if (!NAME_RE.test(name)) { // the Agent tool refuses the whole call for such a name
291      alarm($, `drew "${name.slice(0, 40)}", which is not a valid agent name; this agent runs unnamed`);
292      return next(e);
293    }
294    reserved.add(name);
295    drawn.set(e.tool_use_id, name);
296    try {
297      return await next({ ...e, name });
298    } finally {
299      reserved.delete(name);
300      drawn.delete(e.tool_use_id);
301    }
302  }).catch(($, e, next) => {
303    // Once next was called the name is on the call: what failed is beneath this hook (the
304    // pane's tool.call hook, another plugin, the Agent tool), and is not naming's to report.
305    if (!next.called) alarm($, 'naming failed; this agent runs unnamed');
306    return next(e); // replays the settled call if next already ran; never blocks the dispatch
307  });
308};
309
hooks/pane.tsx 1285 lines
1// The agents pane: a side pane (or, where none docks, a summary above the prompt) listing the subagents a
2// session runs, what each is doing and what it costs in tokens, with each one's conversation a press away.
3//
4// From agentpane 1.1.4 by Anji Xu (https://github.com/xuanji86/claude-agentpane, MIT: see LICENSE-agentpane),
5// as are live.tsx, lanes.tsx and time.ts. Changed here: a name column, the roster (an agent a row, in
6// columns), `idle` read as done or waiting, a shared model said once, paths relative to the session, and
7// this mod's own state keys. The command is /roster here, and one switch (`pane`) turns all of the pane off.
8// This file also holds the two hooks naming shares with the pane, session.start and agent.spawn, since a
9// plugin hooks an event once: naming's part of each is a call into names.ts. They are registered first and
10// stay with the pane switched off; the pane's own hooks follow and do not.
11//
12// To bring a later agentpane in: the copy was taken at upstream commit 17be889 (1.1.4). Clone
13// xuanji86/claude-agentpane, run `git diff 17be889 <new> -- hooks/ types/`, and apply it by hand: its
14// hooks/register.tsx is this file, hooks/pane.test.tsx is tests/pane.test.tsx, hooks/agentpane.test.ts is
15// tests/pane-logic.test.ts, and live.tsx, lanes.tsx, time.ts and types/index.d.ts keep their names. Its
16// hooks/hooks.json is not used here, and its options are in .claude-plugin/plugin.json: diff that too. Then
17// put the new commit in this note and in the README's Credits.
18import { atom, read, update } from 'claude-code'
19import type { AgentInfo, EngineInterface, Register, RenderChildren, RenderElement, SessionMessage, TurnUsage } from 'claude-code'
20
21import type { AgentpaneAgent as Agent, AgentpaneLoop as Loop, AgentpaneTokens as Tokens } from '../types'
22import { fmtDuration, laneRows } from './time'
23import type { Lane } from './time'
24import { NAMES_COMMAND, checkSpawn, namingOn, raise } from './names'
25import type { Say } from './names'
26import { withRunning } from './status'
27
28const PANE = 'agents'
29const TITLE = 'Agents'
30// lazy: polls the engine's agent list every second, as no agent-status event reaches a mod; move to one if it lands.
31const SYNC_MS = 1_000
32const CONFIRM_MS = 5_000 // a pressed Stop waits this long for the press that confirms it
33const SPAWN_GRACE_MS = 10_000 // a spawn the engine's list does not show yet stays this long
34const LOOP_SHOWN_MS = 60_000 // a model loop no agent claims shows while it made a request this recently
35const LOOP_KEPT_MS = 300_000 // and is forgotten after this long
36const INLINE_ROWS = 8 // the pane above the prompt, where it cannot dock: a summary
37const INLINE_READ_ROWS = 24 // and while it shows one conversation
38const PROMPT_LINES = 4 // a prompt is the agent's brief: its head is enough
39const PREVIEW_LINES = 3 // of a tool's result
40const MAX_BLOCKS = 40 // of a conversation, drawn at most
41// The engine refuses a tree with a text over 10,000 characters, or over 100,000 in all: stay well under.
42const MAX_ARG = 300 // characters of a tool call's argument kept
43const MAX_LINE = 500 // characters of one result line kept
44const MAX_REPLY = 6_000 // characters of one reply drawn as Markdown
45const TEXT_BUDGET = 60_000 // characters of a conversation drawn at once
46const GROUP_BUDGET = 30_000 // characters of one opened run of tool calls: its newest calls
47const GROUP_CALLS = 40 // and at most this many of them
48const PREVIEW_SCAN = 8_192 // characters of a tool's result read for its preview
49const MIN_ROWS = 3
50const BATCH_GAP_MS = 60_000 // an agent started this long after the last one ended begins a new batch
51const MAX_LANES = 8 // rows of the time axis
52const DUR_COLS = 8 // a lane's clock, right-aligned
53// The list is a roster: an agent a row, in columns. The right-hand ones go as the pane narrows.
54const CLOCK_COLS = 7 // a row's clock, right-aligned: ' 1m 13s'
55const TOOLS_COLS = 10 // '  12 tools', the numbers in line
56const TOKENS_COLS = 8 // '   80.1k'
57const WITH_TOKENS = 88 // columns of pane from which a row has its token count
58const WITH_TOOLS = 72 // its tool count
59const WITH_DOING = 56 // and what it is doing beside its task; narrower, that goes on a line beneath
60const INLINE_MAX = 110 // columns of the summary above the prompt, so a row's clock stays beside its text
61
62const agents = atom({ plugin: 'named-subagents-mod', key: 'agents' } as const, [])
63const activity = atom({ plugin: 'named-subagents-mod', key: 'activity' } as const, {})
64const viewing = atom({ plugin: 'named-subagents-mod', key: 'viewing' } as const, null)
65const scrollTop = atom({ plugin: 'named-subagents-mod', key: 'scrollTop' } as const, null)
66const rev = atom({ plugin: 'named-subagents-mod', key: 'rev' } as const, 0)
67const expanded = atom({ plugin: 'named-subagents-mod', key: 'expanded' } as const, [])
68const folded = atom({ plugin: 'named-subagents-mod', key: 'folded' } as const, false)
69const tab = atom({ plugin: 'named-subagents-mod', key: 'tab' } as const, false)
70const tokens = atom({ plugin: 'named-subagents-mod', key: 'tokens' } as const, {})
71const loops = atom({ plugin: 'named-subagents-mod', key: 'loops' } as const, {})
72const confirmStop = atom({ plugin: 'named-subagents-mod', key: 'confirmStop' } as const, null)
73const hideDone = atom({ plugin: 'named-subagents-mod', key: 'hideDone' } as const, false)
74
75// Colors are theme keys, so the pane follows the person's theme (dark, light, colorblind) as Claude Code's
76// own rows do. The spinner and shimmer live in live.tsx, on the surface's frame clock.
77const WIDE_SHARE = 0.62 // of the terminal, for the pane while it shows one agent's conversation
78const MIN_WIDE = 60
79const HANDLE_COLS = 2 // the hide handle's column, against the pane's left edge: its glyph and one space
80const RIGHT_MARGIN = 1 // matches that one space on the right, so the content sits centred
81// The handle: a tab several rows tall, each row of it a press target, so a click anywhere on it lands.
82const HANDLE = ['╷ ', '│ ', '▸ ', '│ ', '╵ ']
83const CLOSE_MARK_COLS = 3 // the engine draws its close mark over the end of the pane's first row
84const BAND_RIGHT_PAD = 5 // clear of the engine's [-] band toggle at the band's top right
85
86// The person's settings for the pane (`/config`, or pluginConfigs.named-subagents-mod.options in settings.json).
87export type Config = { pane: boolean; autoOpen: boolean; foldAfterMs: number; motion: boolean; toasts: boolean; keepFinished: number; statusLine: boolean }
88export const parseConfig = (options: unknown): Config => {
89  const o = (options && typeof options === 'object' ? options : {}) as Record<string, unknown>
90  const num = (v: unknown, d: number, lo: number, hi: number) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, Math.round(v))) : d)
91  const bool = (v: unknown, d: boolean) => (typeof v === 'boolean' ? v : d)
92  return {
93    pane: bool(o.pane, true), // the one switch: off, the mod is names only
94    autoOpen: bool(o.autoOpen, true),
95    foldAfterMs: num(o.foldAfter, 10, 0, 3600) * 1000,
96    motion: bool(o.motion, true),
97    toasts: bool(o.toasts, true),
98    keepFinished: num(o.keepFinished, 8, 1, 30),
99    statusLine: bool(o.statusLine, true),
100  }
101}
102let cfg = parseConfig(undefined) // set by register from the options it is given
103
104// "claude-sonnet-5-5" -> "Sonnet 5.5", "claude-haiku-4-5-20251001" -> "Haiku 4.5"; anything else as given.
105export const prettyModel = (id: string | undefined) => {
106  if (!id) return ''
107  const m = /^claude-([a-z]+)-(\d{1,2})(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/.exec(id)
108  if (!m) return id
109  const [, family = '', major, minor, wide] = m
110  return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}${minor ? `.${minor}` : ''}${wide ? ' (1M)' : ''}`
111}
112
113// How hard it is asked to think, as its requests name it: 'high', or a number where the model takes a budget.
114export const prettyEffort = (effort: string | number | undefined) =>
115  effort === undefined || effort === '' ? '' : typeof effort === 'number' ? `effort ${effort}` : effort
116
117// What an agent runs on, for a detail line: 'Opus 5.5 (1M) · high'.
118export const runsOn = (a: { model?: string; effort?: string | number }) =>
119  [prettyModel(a.model), prettyEffort(a.effort)].filter(Boolean).join(' · ')
120
121// How a status reads and shows, wherever an agent's status is drawn.
122export const look = (status: string): { word: string; mark: string; color?: string; dim?: boolean } =>
123  status === 'running' ? { word: 'Running', mark: '✻', color: 'claude' }
124  : status === 'completed' ? { word: 'Done', mark: '✓', color: 'success' }
125  : status === 'failed' ? { word: 'Failed', mark: '✗', color: 'error' }
126  : status === 'killed' ? { word: 'Stopped', mark: '⊘', dim: true }
127  : status === 'idle' ? { word: 'Waiting', mark: '·', dim: true } // a teammate between turns
128  : { word: status, mark: '·', dim: true }
129
130// The emoji below U+1F300 that a terminal draws two columns wide ('✅', '⚡', '⭐'): a roster row is padded to the
131// pane's width cell by cell, so each one miscounted pushes its row a column past the edge.
132const WIDE_EMOJI = /[\u231a\u231b\u23e9-\u23ec\u23f0\u23f3\u25fd\u25fe\u2614\u2615\u2648-\u2653\u267f\u2693\u26a1\u26aa\u26ab\u26bd\u26be\u26c4\u26c5\u26ce\u26d4\u26ea\u26f2\u26f3\u26f5\u26fa\u26fd\u2705\u270a\u270b\u2728\u274c\u274e\u2753-\u2755\u2757\u2795-\u2797\u27b0\u27bf\u2b1b\u2b1c\u2b50\u2b55]/
133
134// Terminal columns a string takes: East Asian wide and fullwidth characters and emoji take two; a skin tone
135// colours the emoji before it and takes none.
136// lazy: range table, not full Unicode East Asian Width; upgrade to a generated table if a script draws wrong.
137export const cols = (s: string) => {
138  let n = 0
139  for (const ch of s) {
140    const c = ch.codePointAt(0) ?? 0
141    if (c >= 0x1f3fb && c <= 0x1f3ff) continue
142    const wide = WIDE_EMOJI.test(ch) ||
143      (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
144      (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) ||
145      (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x1f300 && c <= 0x1faff) || (c >= 0x20000 && c <= 0x3fffd)
146    n += wide ? 2 : 1
147  }
148  return n
149}
150
151// Cut text to `max` columns with an ellipsis.
152export const fit = (s: string, max: number) => {
153  if (cols(s) <= max) return s
154  let out = ''
155  let n = 1
156  for (const ch of s) {
157    if (n + cols(ch) > max) break
158    n += cols(ch)
159    out += ch
160  }
161  return `${out}…`
162}
163
164// Text from the agents' transcripts: no control, escape or bidi characters; tabs as spaces; reminders the
165// engine injected left out.
166const UNSAFE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g
167export const clean = (s: string) =>
168  s.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').replace(/\t/g, '  ').replace(UNSAFE, '').trim()
169const oneLine = (s: string) => clean(s).replace(/\s+/g, ' ')
170
171// Word-wrap to `width` columns; a word longer than a line is cut where the line ends.
172export const wrap = (text: string, width: number): string[] => {
173  const w = Math.max(8, width)
174  const out: string[] = []
175  for (const para of text.split('\n')) {
176    let line = ''
177    let n = 0
178    for (const ch of para) {
179      if (n + cols(ch) > w) {
180        const cut = line.lastIndexOf(' ')
181        if (cut > 0 && cut >= line.length / 2) {
182          out.push(line.slice(0, cut))
183          line = line.slice(cut + 1)
184        } else {
185          out.push(line)
186          line = ''
187        }
188        n = cols(line)
189      }
190      line += ch
191      n += cols(ch)
192    }
193    out.push(line.trimEnd())
194  }
195  return out
196}
197
198// The session's directory, from session.start. A path under it is drawn as the person would type it there.
199let root = ''
200export const setRoot = (cwd: string) => {
201  const dir = cwd.replace(/\/+$/, '')
202  root = dir.length > 1 ? dir : ''
203}
204// Pure: `text` with each path under `cwd` made relative ("/w/src/a.ts" -> "src/a.ts", "/w" -> "."). Only a
205// path that starts a word (after a space, an opening quote, `=`, `(` or a shell mark) and is the directory
206// itself or inside it. Left as written: a sibling ("/w-old", "/w (copy)"), a remote or mounted path
207// ("host:/w/x", "-v /w:/w"), one glued to a variable ("$D/w") and a URL.
208export const relative = (text: string, cwd = root) => {
209  if (!cwd) return text
210  let out = ''
211  let from = 0
212  for (let at = text.indexOf(cwd); at !== -1; at = text.indexOf(cwd, from)) {
213    const before = at ? text[at - 1]! : ' '
214    const quote = before === '"' || before === "'" ? before : ''
215    // A quote opens only at the start of a word: in `"$D"/w/x` it closes one.
216    const opens = quote ? at < 2 || /[\s=(]/.test(text[at - 2]!) : /[\s`=(<>|&;]/.test(before)
217    const rest = text.slice(at + cwd.length, at + cwd.length + 2)
218    const inside = /^\/[^\s"'`)/]/.test(rest)
219    // The directory itself: to the closing quote when quoted, else to a space or a shell mark.
220    const itself = quote ? rest.replace(/^\//, '').startsWith(quote) : /^\/?(?:$|[\s`);|&<>])/.test(rest)
221    out += text.slice(from, at)
222    from = at + cwd.length
223    if (opens && inside) from += 1 // drop "cwd/"
224    else if (opens && itself) (out += '.'), (from += rest.startsWith('/') ? 1 : 0)
225    else out += cwd
226  }
227  return out + text.slice(from)
228}
229
230// A tool call in Claude Code's words: "Bash(npm test)", "Read(register.tsx)", "github:list_issues".
231export const toolParts = (tool: string, input: Record<string, unknown>) => {
232  const s = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
233  const base = (p: string) => p.split(/[\\/]/).pop() ?? p
234  const arg =
235    tool === 'Bash' ? s('command')
236    : ['Read', 'Write', 'Edit', 'NotebookEdit'].includes(tool) ? base(s('file_path') || s('notebook_path'))
237    : tool === 'Grep' || tool === 'Glob' ? s('pattern')
238    : tool === 'WebFetch' ? s('url')
239    : tool === 'WebSearch' ? s('query')
240    : tool === 'Agent' ? s('description')
241    : tool === 'Skill' ? s('skill')
242    : ''
243  const name = oneLine(tool) // as its argument is: a name is the API's to check, and the pane draws it anyway
244  return { name: name.startsWith('mcp__') ? name.split('__').slice(1).join(':') : name, arg: fit(oneLine(relative(arg)), MAX_ARG) }
245}
246export const describeTool = (tool: string, input: Record<string, unknown>) => {
247  const { name, arg } = toolParts(tool, input)
248  return arg ? `${name}(${arg})` : name
249}
250
251// The engine's list over what the pane knew: when each was first seen, and when it was first seen done.
252export const mergeAgents = (before: readonly Agent[], list: readonly AgentInfo[], now: number): Agent[] => {
253  const prev = new Map(before.map(a => [a.id, a]))
254  const listed = new Set(list.map(a => a.id))
255  // A spawn the hook recorded that the engine's list does not show yet: kept a moment, not dropped.
256  const pending = before.filter(a => !listed.has(a.id) && a.status === 'running' && now - a.firstSeen < SPAWN_GRACE_MS)
257  const merged = list.map(info => {
258    const old = prev.get(info.id)
259    // Between turns ('idle') a background agent has given its answer: to the person it is done. A message to
260    // it wakes it as 'running', a new run. A teammate between turns waits for its next message: that stays 'idle'.
261    const status = info.status === 'idle' && !info.teammateId ? 'completed' : info.status
262    const running = status === 'running'
263    const seenRunning = (old?.seenRunning ?? false) || running
264    const endedAt = running ? undefined : (old?.endedAt ?? (seenRunning ? now : undefined))
265    const resumed = running && old?.endedAt !== undefined // ended, then sent a message: a new run from now
266    return {
267      id: info.id, description: info.description, type: info.type, status,
268      ...(info.name && { name: info.name }), ...(info.parentId && { parentId: info.parentId }),
269      firstSeen: resumed ? now : (old?.firstSeen ?? now), seenRunning, ...(endedAt !== undefined && { endedAt }),
270      ...(old?.model && { model: old.model }), ...(old?.effort !== undefined && { effort: old.effort }),
271    }
272  })
273  return [...merged, ...pending]
274}
275
276// A spawn as the pane records it the moment it starts, before the engine's list shows it.
277export const spawned = (list: readonly Agent[], a: Agent): Agent[] =>
278  list.some(x => x.id === a.id) ? list.map(x => (x.id === a.id ? { ...x, model: a.model ?? x.model } : x)) : [...list, a]
279
280// A model loop no agent claims, one more request.
281export const stepLoop = (all: Readonly<Record<string, Loop>>, id: string, now: number): Record<string, Loop> => {
282  const old = all[id]
283  return { ...all, [id]: { firstSeen: old?.firstSeen ?? now, lastSeen: now, requests: (old?.requests ?? 0) + 1 } }
284}
285
286// What the list shows: every running agent and the most recently finished ones, in the engine's order.
287export const visibleAgents = (all: readonly Agent[], keep = 8): Agent[] => {
288  const ended = (a: Agent, i: number) => [a.endedAt ?? a.firstSeen, i] as const
289  const recent = all
290    .map((a, i) => ({ a, key: ended(a, i) }))
291    .filter(({ a }) => a.status !== 'running')
292    .sort((x, y) => y.key[0] - x.key[0] || y.key[1] - x.key[1])
293    .slice(0, keep)
294    .map(({ a }) => a)
295  return all.filter(a => a.status === 'running' || recent.includes(a))
296}
297
298// Pure: the list as groups, each agent whose parent is not listed heading one with its descendants under
299// it; running groups first. With `onlyRunning`, finished agents are left out and their running children
300// head groups of their own.
301export const arrange = (shown: readonly Agent[], onlyRunning = false) => {
302  const list = onlyRunning ? shown.filter(a => a.status === 'running') : shown
303  const ids = new Set(list.map(a => a.id))
304  const under = (id: string) => {
305    const out: { agent: Agent; depth: number }[] = []
306    const walk = (parent: string, depth: number) => {
307      if (depth > 3) return // a loop in the parents, or a tree deeper than the pane can indent
308      for (const a of list) if (a.parentId === parent) out.push({ agent: a, depth }), walk(a.id, depth + 1)
309    }
310    walk(id, 1)
311    return out
312  }
313  const tops = list.filter(a => !a.parentId || !ids.has(a.parentId)).map(a => ({ agent: a, kids: under(a.id) }))
314  return { running: tops.filter(t => t.agent.status === 'running'), finished: tops.filter(t => t.agent.status !== 'running') }
315}
316
317const elapsed = (a: Agent, now: number) => (a.seenRunning ? fmtDuration((a.endedAt ?? now) - a.firstSeen) : '')
318
319// One response's usage added to an agent's tally; `context` is what its latest request carried.
320export const addUsage = (
321  t: Tokens | undefined,
322  u: Pick<TurnUsage, 'input_tokens' | 'output_tokens' | 'cache_read_input_tokens' | 'cache_creation_input_tokens'>,
323  stopReason: string | null = null,
324): Tokens => {
325  const input = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
326  const truncated = (t?.truncated ?? 0) + (stopReason === 'max_tokens' ? 1 : 0)
327  return {
328    ...(truncated && { truncated }),
329    input: (t?.input ?? 0) + input,
330    cached: (t?.cached ?? 0) + u.cache_read_input_tokens,
331    output: (t?.output ?? 0) + u.output_tokens,
332    context: input + u.output_tokens,
333    requests: (t?.requests ?? 0) + 1,
334  }
335}
336
337// "950", "12.3k", "312k", "1.2M".
338export const fmtTokens = (n: number) =>
339  n < 1000 ? String(n) : n < 99_950 ? `${+(n / 1000).toFixed(1)}k` : n < 999_500 ? `${Math.round(n / 1000)}k` : `${+(n / 1_000_000).toFixed(1)}M`
340
341export type CallState = 'running' | 'ok' | 'error' | 'stopped'
342export type Call = { id: string; name: string; arg: string; state: CallState; result: string[]; more: number }
343export type Block =
344  | { kind: 'prompt'; text: string }
345  | { kind: 'reply'; text: string }
346  | { kind: 'tools'; key: string; calls: Call[] }
347const STATE_COLOR: Record<CallState, string> = { running: 'claude', ok: 'success', error: 'error', stopped: 'inactive' }
348
349// A tool's result as its first lines, each cut to length, and how many more it had.
350// Only the head is cleaned and split; the rest, often megabytes, is counted by its newlines.
351export const preview = (text: string) => {
352  const lines = clean(text.slice(0, PREVIEW_SCAN)).split('\n').map(l => l.trimEnd()).filter(l => l.trim())
353  let rest = 0 // lazy: counts blank lines past the head too; exact would need the whole text split
354  for (let i = text.indexOf('\n', PREVIEW_SCAN); i !== -1; i = text.indexOf('\n', i + 1)) rest++
355  return { result: lines.slice(0, PREVIEW_LINES).map(l => fit(l, MAX_LINE)), more: Math.max(0, lines.length - PREVIEW_LINES) + rest }
356}
357
358// Pure: an agent's conversation as blocks: its brief (and any later message to it), each reply, and each
359// run of tool calls between them as one group. A call left unanswered by an agent that `ended` was cut off.
360export const transcriptBlocks = (msgs: readonly SessionMessage[], ended = false): Block[] => {
361  const out: Block[] = []
362  for (const m of msgs) {
363    const text = clean(m.text)
364    if (m.role === 'user') {
365      if (text) out.push({ kind: 'prompt', text })
366      continue
367    }
368    if (text) out.push({ kind: 'reply', text })
369    for (const use of m.toolUses) {
370      const { name, arg } = toolParts(use.tool, use.input)
371      const state: CallState = use.text === undefined ? (ended ? 'stopped' : 'running') : use.isError ? 'error' : 'ok'
372      const call: Call = { id: use.tool_use_id, name, arg, state, ...(use.text === undefined ? { result: [], more: 0 } : preview(use.text)) }
373      const last = out[out.length - 1]
374      if (last?.kind === 'tools') last.calls.push(call)
375      else out.push({ kind: 'tools', key: use.tool_use_id, calls: [call] })
376    }
377  }
378  return out
379}
380
381// Pure: the blocks to draw, within MAX_BLOCKS and the text budget: from `top` down when scrolled back, the
382// newest otherwise. Always at least one.
383export const pickBlocks = (blocks: readonly Block[], top: number | null) => {
384  const size = (b: Block) =>
385    b.kind === 'tools' ? b.calls.reduce((n, c) => n + c.arg.length + c.result.join('').length + 40, 0) : Math.min(b.text.length, MAX_REPLY) + 40
386  const out: Block[] = []
387  let used = 0
388  const take = (b: Block | undefined, add: (b: Block) => void) => {
389    if (!b || out.length >= MAX_BLOCKS) return false
390    used += size(b)
391    if (used > TEXT_BUDGET && out.length) return false
392    add(b)
393    return true
394  }
395  if (top === null) for (let i = blocks.length - 1; take(blocks[i], b => out.unshift(b)); i--);
396  else for (let i = Math.max(0, top); take(blocks[i], b => out.push(b)); i++);
397  return out
398}
399
400// Pure: the newest calls of an opened run that fit GROUP_BUDGET and GROUP_CALLS (always one), and how many
401// earlier ones are left out: an agent that only calls tools makes one run of hundreds.
402export const lastCalls = (calls: readonly Call[]) => {
403  let used = 0
404  let from = calls.length
405  while (from > 0 && calls.length - from < GROUP_CALLS) {
406    const c = calls[from - 1]!
407    used += c.arg.length + c.result.join('').length + 40
408    if (used > GROUP_BUDGET && from < calls.length) break
409    from--
410  }
411  return { shown: calls.slice(from), hidden: from }
412}
413
414// A run of tool calls in Claude Code's words: "Read 3 files, ran 2 commands".
415const VERBS: Record<string, [string, string, string]> = {
416  Read: ['read', 'file', 'files'], Bash: ['ran', 'command', 'commands'], Edit: ['edited', 'file', 'files'],
417  Write: ['wrote', 'file', 'files'], NotebookEdit: ['edited', 'notebook', 'notebooks'], Grep: ['searched for', 'pattern', 'patterns'],
418  Glob: ['searched for', 'pattern', 'patterns'], WebFetch: ['fetched', 'page', 'pages'], WebSearch: ['searched the web', 'time', 'times'],
419  Agent: ['ran', 'agent', 'agents'],
420}
421export const groupSummary = (calls: readonly Call[]) => {
422  const counts = new Map<string, number>()
423  for (const c of calls) counts.set(c.name, (counts.get(c.name) ?? 0) + 1)
424  const parts = [...counts].map(([name, n]) => {
425    const verb = VERBS[name]
426    return verb ? `${verb[0]} ${n} ${n === 1 ? verb[1] : verb[2]}` : `called ${name}${n > 1 ? ` ${n} times` : ''}`
427  })
428  const text = parts.join(', ')
429  return text.charAt(0).toUpperCase() + text.slice(1)
430}
431const groupState = (calls: readonly Call[]): CallState =>
432  calls.some(c => c.state === 'running') ? 'running'
433  : calls.some(c => c.state === 'error') ? 'error'
434  : calls.some(c => c.state === 'stopped') ? 'stopped'
435  : 'ok'
436
437// The name an agent was given (its Agent call's `name`, or one a naming mod set), cleaned and cut; '' when none.
438export const givenName = (a: Pick<Agent, 'name'>, max = 16) => (a.name ? fit(oneLine(a.name), max) : '')
439// The type an agent has when its call names none: a named agent's conversation header leaves it out.
440const DEFAULT_TYPE = 'general-purpose'
441// An agent's task: beside a given name its description alone (its type shows in its conversation's header); without
442// a name, as Claude Code draws an Agent call, type and description. Cleaned and cut.
443const taskOf = (a: Agent, max = 60) =>
444  givenName(a) ? fit(oneLine(a.description || a.type), max) : `${fit(oneLine(a.type), 30)}(${fit(oneLine(a.description || a.type), max)})`
445// An agent as the pane's toasts and its Stop say it: its name when it was given one, then its task.
446export const nameOf = (a: Agent, max = 60) => [givenName(a), taskOf(a, max)].filter(Boolean).join(' ')
447// The list's name column: each name padded to the widest shown, and a gap; '' on every row when no agent has one.
448export const nameColumn = (shown: readonly Agent[]) => {
449  const width = Math.max(0, ...shown.map(a => cols(givenName(a))))
450  // An agent the column was not built from (one off the list, still in the batch) may be wider than it.
451  return (a: Agent) => (width ? givenName(a) + ' '.repeat(Math.max(0, width - cols(givenName(a))) + 2) : '')
452}
453
454// Pure: a roster row's columns in a pane `width` wide, after its mark: the name (`nameCols` where it fits,
455// less where the task would have under 8 columns), the task, what the agent is doing (0: none), its tool count
456// and its tokens (0: left out). The task takes what the longest one shown needs (`taskCols`), up to three
457// fifths of the room it shares with the doing. `beneath`: a narrow pane has a line under a row for what the
458// agent is doing, so the column goes; without one (the summary above the prompt) it stays while it has 8 columns.
459const MIN_TASK = 8
460export const rosterColumns = (width: number, nameCols: number, taskCols: number, beneath = true) => {
461  const tokens = width >= WITH_TOKENS ? TOKENS_COLS : 0
462  const tools = width >= WITH_TOOLS ? TOOLS_COLS : 0
463  const name = Math.max(0, Math.min(nameCols, width - 2 - CLOCK_COLS - tools - tokens - MIN_TASK))
464  const rest = Math.max(MIN_TASK, width - 2 - name - CLOCK_COLS - tools - tokens)
465  const task = Math.max(MIN_TASK, Math.min(taskCols + 2, Math.floor(rest * 0.6)))
466  const doing = (beneath ? width >= WITH_DOING : rest - task >= MIN_TASK) ? rest - task : 0
467  return { name, task: rest - doing, doing, tools, tokens }
468}
469
470// Pure: what every agent shown runs on, when they all run on the same ('Haiku 4.5 · high'): said once, in
471// the header. '' when they differ or none is known yet; each row then says its own.
472export const sharedModel = (shown: readonly Pick<Agent, 'model' | 'effort'>[]) => {
473  const known = [...new Set(shown.map(runsOn).filter(Boolean))]
474  return known.length === 1 ? known[0]! : ''
475}
476
477// Pure: the toast for agents that finished since the last look, or null when none did.
478export const finishNotice = (before: readonly Agent[], after: readonly Agent[], now: number) => {
479  const ended = after.filter(a => a.status !== 'running' && before.some(b => b.id === a.id && b.status === 'running'))
480  if (!ended.length) return null
481  if (ended.length === 1) {
482    const a = ended[0]!
483    const l = look(a.status)
484    const t = elapsed(a, now)
485    // By its name when it was given one, its task after; without one the task is all there is to call it.
486    const given = givenName(a)
487    return `${l.mark} ${given || taskOf(a, 40)} ${l.word.toLowerCase()}${t ? ` · ${t}` : ''}${given ? ` · ${taskOf(a, 40)}` : ''}`
488  }
489  const failed = ended.filter(a => a.status === 'failed').length
490  return `✓ ${ended.length} agents finished${failed ? ` · ✗ ${failed} failed` : ''}`
491}
492
493const uses = (n: number) => `${n} tool use${n === 1 ? '' : 's'}`
494
495// Pure: the latest batch, oldest first: the agents that ran, back from the newest to a start more than
496// BATCH_GAP_MS after everything before it had ended.
497export const latestBatch = (all: readonly Agent[]): Agent[] => {
498  const ran = all.filter(a => a.seenRunning).sort((x, y) => x.firstSeen - y.firstSeen)
499  let start = 0
500  let reach = -Infinity // when the agents so far had all ended; never, while one runs
501  ran.forEach((a, i) => {
502    if (a.firstSeen > reach + BATCH_GAP_MS) start = i
503    reach = Math.max(reach, a.status === 'running' ? Infinity : (a.endedAt ?? a.firstSeen))
504  })
505  return ran.slice(start)
506}
507
508// Pure: what a batch of two or more came to, once none of it runs: how long it took, how much agent time
509// that was and so how much ran side by side, and what it used.
510export const batchReceipt = (
511  batch: readonly Agent[],
512  act: Readonly<Record<string, { tools: number }>>,
513  tok: Readonly<Record<string, Tokens>>,
514) => {
515  if (batch.length < 2 || batch.some(a => a.status === 'running')) return null
516  const end = (a: Agent) => a.endedAt ?? a.firstSeen
517  const wall = Math.max(...batch.map(end)) - Math.min(...batch.map(a => a.firstSeen))
518  // An agent's subagents run inside its own time: only the agents at the top of the batch add to it.
519  const work = batch.filter(a => !batch.some(p => p.id === a.parentId)).reduce((n, a) => n + end(a) - a.firstSeen, 0)
520  const count = (s: string) => batch.filter(a => a.status === s).length
521  const tools = batch.reduce((n, a) => n + (act[a.id]?.tools ?? 0), 0)
522  const used = batch.reduce((n, a) => n + (tok[a.id] ? tok[a.id]!.input + tok[a.id]!.output : 0), 0)
523  const together = wall > 0 ? work / wall : 1
524  const who = [...new Set(['completed', 'failed', 'killed', ...batch.map(a => a.status)])]
525    .map(s => (count(s) ? `${count(s)} ${look(s).word.toLowerCase()}` : '')).filter(Boolean).join(' · ')
526  return {
527    failed: count('failed') > 0,
528    text: [
529      `${who} in ${fmtDuration(wall)}`,
530      `${fmtDuration(work)} of agent time${together >= 1.2 ? ` (${together.toFixed(1)}× in parallel)` : ''}`,
531      tools ? uses(tools) : '',
532      used ? `${fmtTokens(used)} tokens` : '',
533    ].filter(Boolean).join(' · '),
534  }
535}
536
537// Pure: the status line under the prompt while a batch runs, or undefined to clear it.
538export const statusOf = (batch: readonly Agent[]) => {
539  const running = batch.filter(a => a.status === 'running').length
540  if (!running) return undefined
541  const failed = batch.filter(a => a.status === 'failed').length
542  const of = running === batch.length ? `${running} agent${running === 1 ? '' : 's'} running` : `${running} of ${batch.length} agents running`
543  return `${look('running').mark} ${of}${failed ? ` · ${look('failed').mark} ${failed} failed` : ''}`
544}
545
546// Module state that nothing draws from (a reload resets it to "nothing opened yet").
547let autoOpened = false // the mod opened the pane unasked, rather than the person
548let closedByPerson = false // closed by hand while agents ran: stays shut until a new one starts
549let foldingAway = false // the close under way is a fold: the tab above the prompt brings the pane back
550let foldWhenIdle = false // agents ran while the pane was open: fold it once they are all done
551let isFullscreen: boolean | undefined // from the last drawing: only the fullscreen layout docks a pane
552let lastPlacement: string | null = null
553let lastColumns = 0 // the terminal's width, from the last drawing
554let lastRunningAt = 0
555let armedAt = 0 // when Stop was pressed once
556// The status line as last set, so it is set only when it changes; null: not yet by this module, so a line a
557// reload left behind is cleared on the first sync.
558let lastStatus: string | undefined | null = null
559let viewingId: string | null = null // the conversation on screen, for the events that redraw it
560let transcript = { key: '', blocks: [] as Block[] } // that conversation's blocks, read once per change
561// lazy: one sync at a time by a module flag; a slow agent.list only skips ticks.
562let syncing = false
563// Agents the spawn hook listed since the last sync: new to the pane, though the list already holds them.
564const spawnedSinceSync = new Set<string>()
565
566// Never rejects. Keeps the list current, opens the pane when an agent starts, folds it once they are done.
567async function sync($: EngineInterface) {
568  if (syncing) return
569  syncing = true
570  try {
571    const now = await $.clock.now()
572    // The desktop app has its own agents view: with no terminal drawing, nothing opens or shows unasked there.
573    const surfaces = await $.session.surfaces().catch(() => [])
574    const quiet = surfaces.includes('desktop') && !surfaces.includes('terminal')
575    const info = await $.agent.list()
576    const before = await read($, agents)
577    let list = mergeAgents(before, info, now)
578    // Written over the list as it stands then, so an agent the spawn hook records meanwhile keeps its model.
579    if (JSON.stringify(list) !== JSON.stringify(before)) list = await update($, agents, cur => mergeAgents(cur, info, now))
580    const notice = finishNotice(before, list, now)
581    if (notice && cfg.toasts && !quiet) $.ui.toast(notice)
582    const ids = new Set(list.map(a => a.id))
583    if (Object.keys(await read($, activity)).some(id => !ids.has(id)))
584      await update($, activity, m => Object.fromEntries(Object.entries(m).filter(([id]) => ids.has(id))))
585
586    const running = list.filter(a => a.status === 'running')
587    const pane = (await $.ui.panes()).find(p => p.id === PANE)
588    // A new agent unfolds the pane, whoever folded or closed it, and shows the list.
589    const fresh = running.some(a => spawnedSinceSync.has(a.id) || !before.some(b => b.id === a.id))
590    for (const a of list) spawnedSinceSync.delete(a.id) // a spawn recorded after this list was read waits for the next
591    if (fresh) {
592      closedByPerson = false
593      // With autoOpen off the pane stays shut, and a fold the person made keeps its tab.
594      if (!pane && cfg.autoOpen) await showList($, { clearFold: true })
595    }
596    // The conversation on screen belongs to an agent no longer listed (/clear): back to the list, so it can fold.
597    const open = await read($, viewing)
598    if (open && !ids.has(open)) await showList($)
599    if (running.length) lastRunningAt = now
600    if (pane && running.length) foldWhenIdle = true
601    if (!pane && running.length && cfg.autoOpen && !quiet && !closedByPerson && !(await read($, folded))) {
602      autoOpened = true
603      await openPane($, 'list') // docked beside a fullscreen transcript, else the summary above the prompt
604    } else if (
605      pane && foldWhenIdle && !running.length && cfg.foldAfterMs > 0 && now - lastRunningAt >= cfg.foldAfterMs &&
606      !pane.isFocused && !(await read($, viewing))
607    ) {
608      await foldAway($) // all done; not while the person reads a conversation
609    }
610    // Folded, the tab above the prompt stands in for the pane while the session has agents to show.
611    const showTab = (await read($, folded)) && list.length > 0
612    if (showTab !== (await read($, tab))) await update($, tab, () => showTab)
613    if ((await read($, confirmStop)) && now - armedAt > CONFIRM_MS) await update($, confirmStop, () => null)
614
615    // Loops that turned out to be agents, or went quiet long ago, are forgotten; tokens of neither go too.
616    const loopsNow = await read($, loops)
617    const keptLoops = Object.fromEntries(Object.entries(loopsNow).filter(([id, l]) => !ids.has(id) && now - l.lastSeen < LOOP_KEPT_MS))
618    if (Object.keys(keptLoops).length !== Object.keys(loopsNow).length) await update($, loops, () => keptLoops)
619    const kept = (id: string) => ids.has(id) || id in keptLoops
620    if (Object.keys(await read($, tokens)).some(id => !kept(id)))
621      await update($, tokens, m => Object.fromEntries(Object.entries(m).filter(([id]) => kept(id))))
622
623    // Under the prompt while a batch runs and the pane is not on screen (folded, closed, or waiting for room).
624    const placed = cfg.statusLine && (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced && p.isShown)
625    const status = cfg.statusLine && !placed && !quiet ? statusOf(latestBatch(list)) : undefined
626    if (status !== lastStatus) {
627      lastStatus = status
628      $.ui.status(withRunning(status)) // the line is naming's too: its alarm stands until it clears
629    }
630  } catch {
631    // the next tick tries again
632  } finally {
633    syncing = false
634  }
635}
636
637// Back to the list, live, nothing armed; `clearFold` also clears the fold.
638async function showList($: EngineInterface, { clearFold = false } = {}) {
639  await update($, viewing, () => null)
640  await update($, scrollTop, () => null)
641  await update($, expanded, () => [])
642  await update($, confirmStop, () => null)
643  if (clearFold) await update($, folded, () => false)
644}
645
646// The pane sized for what it shows: docked, at its usual width or widened to read a conversation; above the
647// prompt, a summary's rows or a conversation's. `focus` asks for the keyboard (granted over an empty
648// composer), so the conversation's b/k/j keys work.
649async function openPane($: EngineInterface, view: 'list' | 'conversation', focus = false) {
650  const docked = lastPlacement ? lastPlacement === 'dock' : isFullscreen !== false
651  const columns = docked && view === 'conversation' && lastColumns ? Math.max(MIN_WIDE, Math.round(lastColumns * WIDE_SHARE)) : undefined
652  const rows = docked ? undefined : view === 'conversation' ? INLINE_READ_ROWS : INLINE_ROWS
653  await $.ui.open({ id: PANE, title: TITLE, ...(columns && { columns }), ...(rows && { rows }), ...(focus && { focus: true as const }) })
654}
655
656// Fold: the pane closes, so the transcript has the width back, and a tab above the prompt reopens it.
657async function foldAway($: EngineInterface) {
658  await update($, folded, () => true)
659  await update($, tab, () => true)
660  foldingAway = true
661  try {
662    await $.ui.close({ id: PANE })
663  } finally {
664    foldingAway = false
665  }
666}
667
668// The person unfolds it from the tab: theirs now, so it folds again only after agents run in it.
669async function unfold($: EngineInterface) {
670  await update($, folded, () => false)
671  await update($, tab, () => false)
672  closedByPerson = false
673  autoOpened = false
674  foldWhenIdle = (await read($, agents)).some(a => a.status === 'running')
675  await openPane($, (await read($, viewing)) ? 'conversation' : 'list')
676}
677
678// The pane is switched off. Flipped mid-session, the reload finds what the pane left: its count under the
679// prompt, the pane itself if open, and its list, fold and open conversation in the session's state. All
680// three go, so switching it on again starts clean (an agent that finished meanwhile would else be announced
681// then, with the whole time off as its run). A session that starts with the pane off has none of them, and
682// nothing is closed or written. Never rejects.
683async function putAway($: EngineInterface) {
684  try {
685    // The whole line: a reload has already forgotten naming's alarm (status.ts), as it does with the pane on.
686    $.ui.status(withRunning(undefined))
687    if ((await $.ui.panes()).some(p => p.id === PANE)) await $.ui.close({ id: PANE })
688    if ((await read($, agents)).length || (await read($, folded)) || (await read($, tab)) || (await read($, viewing))) {
689      await update($, agents, () => [])
690      await update($, tab, () => false)
691      await showList($, { clearFold: true })
692    }
693  } catch { // audit-allow: fail-loud — a pane that could not be closed is on screen, which says so itself
694  }
695}
696
697// Stop a running agent with Claude Code's own TaskStop, on the person's confirmed press of Stop.
698async function stopAgent($: EngineInterface, a: Agent) {
699  const name = nameOf(a)
700  const done = (await $.tool
701    // The consent reads to the engine as the person's words: the type and id, never the description a model wrote.
702    .call({ tool: 'TaskStop', task_id: a.id, consent: `The user pressed "Stop" on the ${JSON.stringify(fit(oneLine(a.type), 30))} agent ${fit(a.id, 40)} in the agents pane` } as never)
703    .catch((err: unknown) => ({ deny: String(err) }))) as { deny?: string; isError?: boolean; text?: string }
704  const why = done.deny || (done.isError ? oneLine(String(done.text ?? '')) || 'the tool reported an error' : '')
705  if (why) $.ui.toast(`Could not stop ${name}: ${fit(oneLine(why), 120)}`)
706}
707
708// The conversation on screen changed: draw it again.
709const redraw = ($: EngineInterface, agentId: string | undefined) => {
710  if (agentId && agentId === viewingId) void update($, rev, n => n + 1).catch(() => undefined)
711}
712
713// Naming's alarm, for its part of the two shared hooks: a toast and the status line.
714const sayOf = ($: EngineInterface): Say => ({ toast: line => $.ui.toast(line, { timeoutMs: 10000 }), status: line => $.ui.status(line) })
715
716export const register: Register = (on, options) => {
717  cfg = parseConfig(options)
718  const naming = namingOn(options)
719
720  on('session.start', async ($, e, next) => {
721    setRoot(e.cwd)
722    if (naming) await Promise.resolve($.command.register(NAMES_COMMAND)).catch(() => raise(sayOf($), 'could not register /names'))
723    if (!cfg.pane) {
724      void putAway($) // not awaited: a session start does not wait on the pane's leftovers
725      return next(e)
726    }
727    $.clock.every(SYNC_MS, () => void sync($)) // before the command: without it the pane still lists, counts and opens
728    await Promise.resolve($.command.register({ name: 'roster', description: 'Show or hide the pane of running agents' }))
729      .catch(() => raise(sayOf($), 'could not register /roster'))
730    return next(e)
731  })
732
733  // An agent is listed the moment it starts, with the model it runs on, ahead of the next poll.
734  on('agent.spawn', async ($, e, next) => {
735    const started = await next(e)
736    const id = started.agentId
737    // Naming's check that the name it drew for this call is the one the agent got. First, and it never
738    // throws: nothing the pane does below can skip it.
739    if (naming) await checkSpawn(sayOf($), () => $.agent.list(), e, id)
740    if (cfg.pane && id && !started.deny) {
741      const a: Agent = {
742        id, description: e.description, type: e.subagentType, status: 'running',
743        ...(e.parentAgentId && { parentId: e.parentAgentId }), ...(e.name && { name: e.name }),
744        firstSeen: await $.clock.now(), seenRunning: true, ...(started.model && { model: started.model }),
745      }
746      spawnedSinceSync.add(id)
747      void update($, agents, list => spawned(list, a)).catch(() => undefined)
748    }
749    return started
750  }).catch(($, e, next) => next(e)) // replays the spawn if it already ran; never refuses one
751
752  // The pane switched off: the two hooks above are naming's too and stay; none of the pane's own below.
753  // A session cannot take a command back, so a /roster registered before the switch was flipped is still
754  // offered until the session ends: it says why nothing opens.
755  if (!cfg.pane) {
756    on('command.run', { command: 'roster' }, async () => ({ text: 'The agents pane is switched off. "Show the agents pane" in /config turns it on.' }))
757    return
758  }
759
760  on('command.run', { command: 'roster' }, async $ => {
761    const pane = (await $.ui.panes()).find(p => p.id === PANE)
762    if (pane?.isPlaced) {
763      await $.ui.close({ id: PANE })
764      return { text: 'Agents pane closed.' }
765    }
766    autoOpened = false // opened by hand: the mod leaves it open
767    closedByPerson = false
768    foldWhenIdle = false
769    await update($, folded, () => false)
770    await update($, tab, () => false)
771    await openPane($, 'list', true) // asked for, so placed even where an unasked pane waits
772    return { text: 'Agents pane opened.' }
773  })
774
775  on('ui.close', async ($, e, next) => {
776    const result = await next(e)
777    if (e.id === PANE && !('deny' in result && result.deny)) {
778      foldWhenIdle = false
779      autoOpened = false
780      await update($, confirmStop, () => null)
781      if (!foldingAway) {
782        // closed by hand (its close mark, Esc, ctrl+x x, /roster) while agents run: stays shut until a new one starts
783        closedByPerson = (await read($, agents)).some(a => a.status === 'running')
784        await update($, viewing, () => null)
785      }
786    }
787    return result
788  })
789
790  // What each agent is doing: its latest tool call; and its conversation, if on screen, grows.
791  on('tool.call', async ($, e, next) => {
792    const id = e.agentId
793    if (id) {
794      const text = describeTool(e.tool, e as unknown as Record<string, unknown>)
795      void update($, activity, m => ({ ...m, [id]: { text, tools: (m[id]?.tools ?? 0) + 1 } })).catch(() => undefined)
796      redraw($, id)
797    }
798    const result = await next(e)
799    redraw($, id)
800    return result
801  })
802
803  // What each agent's responses cost, as the API reported each one; a response grows its conversation. A
804  // loop no listed agent claims (a workflow's agent, a compaction or memory fork) is counted on its own.
805  on('turn.step', async function* ($, e, next) {
806    const result = yield* next(e)
807    const id = e.agentId
808    if (id) {
809      const usage = result?.usage
810      if (usage) void update($, tokens, m => ({ ...m, [id]: addUsage(m[id], usage, result.stopReason) })).catch(() => undefined)
811      const listed = (await read($, agents)).find(a => a.id === id)
812      // The model and effort its requests name, as the engine resolved them: an agent whose spawn named no model (a
813      // forked skill, an inherited model) still shows what it runs on, and an alias the spawn gave ('sonnet') reads as
814      // the real id. Effort is the step's alone (a spawn reports none); absent for a model without it.
815      if (listed && ((e.model && listed.model !== e.model) || listed.effort !== e.effort)) {
816        const ran = { ...(e.model && { model: e.model }), effort: e.effort }
817        void update($, agents, l => l.map(a => (a.id === id ? { ...a, ...ran } : a))).catch(() => undefined)
818      }
819      if (!listed) {
820        const now = await $.clock.now()
821        void update($, loops, l => stepLoop(l, id, now)).catch(() => undefined)
822      }
823    }
824    redraw($, id)
825    return result
826  })
827
828  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
829    const els = $.ui.resolve(e)
830    const { Box, Button, Markdown, Text } = els
831    // Live lines run on the surface's own frame clock where it has one (terminal, desktop); elsewhere they are
832    // static. Every surface's table names Client, but on the others it draws nothing: ask by surface.
833    const Client = (e.surface === 'terminal' || e.surface === 'desktop') && 'Client' in els ? els.Client : null
834    lastPlacement = e.props.placement
835    lastColumns = e.viewport?.columns ?? lastColumns
836    isFullscreen = e.viewport?.isFullscreen ?? isFullscreen
837    const inline = e.props.placement === 'inline' // above the prompt: a summary, no handle
838    const W = Math.max(22, e.props.bodyColumns - (inline ? 0 : HANDLE_COLS) - RIGHT_MARGIN) // the content, right of the handle
839    const viewed = e.props.view?.agentId ?? null // the agent the person has open in the main view
840    const rows = Math.max(MIN_ROWS + 6, e.props.scroll.bodyRows)
841    const now = await $.clock.now()
842    const all = await read($, agents)
843    const act = await read($, activity)
844    const tok = await read($, tokens)
845    const id = await read($, viewing)
846    const agent = id ? all.find(a => a.id === id) : undefined
847    viewingId = agent ? agent.id : null
848    const runningCount = all.filter(a => a.status === 'running').length
849    const doneCount = all.length - runningCount
850    const hint = (text: string) => <Text dimColor wrap="truncate-end">{text}</Text>
851    const alertOf = (a: Agent) => (tok[a.id]?.truncated ? `max_tokens ×${tok[a.id]!.truncated}` : '')
852    // A running agent's mark, spinning on the surface's clock where it has one; a still mark elsewhere.
853    const markOf = (a: Agent, pad: string) => {
854      const l = look(a.status)
855      return Client && a.status === 'running' && a.seenRunning && cfg.motion ? (
856        <Client key={`mark-${a.id}`} module="./live.tsx" props={{ since: a.firstSeen, now, pad, detail: '', alert: '', motion: true, clockOnly: false, markOnly: true }} />
857      ) : (
858        <Text color={l.color} dimColor={l.dim}>{`${pad}${l.mark} `}</Text>
859      )
860    }
861    const clock = (a: Agent) =>
862      Client && a.status === 'running' && a.seenRunning ? (
863        <Client key={`clock-${a.id}`} module="./live.tsx" props={{ since: a.firstSeen, now, pad: '', detail: '', alert: '', motion: false, clockOnly: true }} />
864      ) : (
865        <Text dimColor>{elapsed(a, now)}</Text>
866      )
867    // The first row, clear of the engine's close mark at its end.
868    const header = (left: RenderChildren, right: RenderChildren, width = W) => (
869      <Box width={width - CLOSE_MARK_COLS} justifyContent="space-between">
870        {left}
871        {typeof right === 'string' ? <Text dimColor>{right}</Text> : right}
872      </Box>
873    )
874    // A handle on the pane's left edge, halfway down: ▸ hides the pane. Above the prompt there is no edge to hold.
875    const withHandle = (body: RenderChildren) =>
876      inline ? (
877        <Box flexDirection="column" width={W}>
878          {body}
879        </Box>
880      ) : (
881      <Box flexDirection="row">
882        <Box key="handle" flexDirection="column" width={HANDLE_COLS} flexShrink={0}>
883          {Array.from({ length: Math.max(0, Math.floor((rows - HANDLE.length) / 2)) }, () => <Text> </Text>)}
884          {HANDLE.map((glyph, i) => (
885            <Button
886              key={glyph.startsWith('▸') ? 'collapse' : `collapse-${i}`}
887              label={glyph}
888              plain
889              dimColor
890              hover={{ color: 'claude', bold: true }}
891              onPress={() => foldAway($)}
892            />
893          ))}
894        </Box>
895        <Box flexDirection="column" width={W} height={rows}>
896          {body}
897        </Box>
898      </Box>
899      )
900
901    // One agent's conversation, as Claude Code draws its own: > the brief, ⏺ replies and tool calls, ⎿ results.
902    if (agent) {
903      const key = `${agent.id}:${await read($, rev)}:${agent.status}`
904      if (transcript.key !== key) {
905        const msgs = await $.session.messages({ agentId: agent.id })
906        transcript = { key, blocks: Array.isArray(msgs) ? transcriptBlocks(msgs, agent.status !== 'running') : [] }
907      }
908      const blocks = transcript.blocks
909      const open = new Set(await read($, expanded))
910      const armed = await read($, confirmStop)
911      const savedTop = await read($, scrollTop)
912      const top = savedTop === null ? null : Math.min(savedTop, Math.max(0, blocks.length - 1))
913      const shown = pickBlocks(blocks, top)
914      const newer = top === null ? 0 : blocks.length - top - 1 // blocks after the one at the top
915      const used = tok[agent.id]
916      const l = look(agent.status)
917      const given = givenName(agent)
918      const leave = async () => {
919        await showList($)
920        await openPane($, 'list') // back to the usual size
921      }
922      const toggle = (k: string) => update($, expanded, list => (list.includes(k) ? list.filter(x => x !== k) : [...list, k]))
923      const result = (c: Call) =>
924        c.state === 'running' ? [<Text color="claude">{'  ⎿  Running…'}</Text>]
925        : c.state === 'stopped' ? [<Text dimColor>{'  ⎿  Interrupted'}</Text>]
926        : [
927            ...(c.result.length ? c.result : ['(No output)']).map((r, i) => (
928              <Text color={c.state === 'error' ? 'error' : undefined} dimColor={c.state !== 'error'} wrap="truncate-end">
929                {`${i ? '     ' : '  ⎿  '}${r}`}
930              </Text>
931            )),
932            ...(c.more ? [<Text dimColor>{`     … +${c.more} line${c.more === 1 ? '' : 's'}`}</Text>] : []),
933          ]
934      const call = (c: Call) => (
935        <Box flexDirection="column" flexShrink={0}>
936          <Text wrap="truncate-end">
937            <Text color={STATE_COLOR[c.state]}>⏺ </Text>
938            <Text bold>{c.name}</Text>
939            <Text>{c.arg ? `(${c.arg})` : ''}</Text>
940          </Text>
941          {result(c)}
942        </Box>
943      )
944      const block = (b: Block): RenderElement => {
945        if (b.kind === 'prompt') {
946          const lines = wrap(b.text.slice(0, PROMPT_LINES * W * 2), W - 2)
947          return (
948            <Box flexDirection="column" flexShrink={0} marginTop={1}>
949              {lines.slice(0, PROMPT_LINES).map((line, i) => (
950                <Text dimColor wrap="truncate-end">{`${i ? '  ' : '> '}${line}`}</Text>
951              ))}
952              {lines.length > PROMPT_LINES ? <Text dimColor>{'  …'}</Text> : null}
953            </Box>
954          )
955        }
956        if (b.kind === 'reply') {
957          const text = b.text.length > MAX_REPLY ? `${b.text.slice(0, MAX_REPLY)}\n\n… (${b.text.length - MAX_REPLY} more characters)` : b.text
958          return (
959            <Box flexDirection="row" flexShrink={0} marginTop={1}>
960              <Text>⏺ </Text>
961              <Box flexDirection="column" width={W - 2}>
962                <Markdown text={text} />
963              </Box>
964            </Box>
965          )
966        }
967        if (b.calls.length === 1) return <Box flexShrink={0} marginTop={1}>{call(b.calls[0]!)}</Box>
968        const isOpen = open.has(b.key)
969        const opened = lastCalls(b.calls)
970        const live = b.calls.find(c => c.state === 'running')
971        const failed = b.calls.find(c => c.state === 'error')
972        return (
973          <Box key={`grp-${b.key}`} flexDirection="column" flexShrink={0} marginTop={1}>
974            <Box>
975              <Text color={STATE_COLOR[groupState(b.calls)]}>⏺ </Text>
976              <Button key={`group-${b.key}`} label={groupSummary(b.calls)} plain hover={{ color: 'claude' }} onPress={() => toggle(b.key)} />
977              <Text dimColor>{isOpen ? ' (click to collapse)' : ' (click to expand)'}</Text>
978            </Box>
979            {isOpen ? (
980              <Box flexDirection="column" paddingLeft={2}>
981                {opened.hidden ? <Text dimColor>{`… +${opened.hidden} earlier call${opened.hidden === 1 ? '' : 's'}`}</Text> : null}
982                {opened.shown.map(call)}
983              </Box>
984            ) : live ? (
985              <Text color="claude" wrap="truncate-end">{`  ⎿  ${live.name}${live.arg ? `(${live.arg})` : ''} · Running…`}</Text>
986            ) : failed ? (
987              <Text color="error" wrap="truncate-end">{`  ⎿  ${failed.name} failed: ${failed.result[0] ?? ''}`}</Text>
988            ) : null}
989          </Box>
990        )
991      }
992      const arm = async () => {
993        armedAt = await $.clock.now()
994        await update($, confirmStop, () => agent.id)
995      }
996      return withHandle([
997        header(
998          <Box>
999            <Button key="back" label="←" hotkey="b" dimColor onPress={leave} />
1000            <Text color={l.color} dimColor={l.dim}> ⏺ </Text>
1001            {given ? <Text bold>{`${given} `}</Text> : null}
1002            {given && agent.type === DEFAULT_TYPE ? null : <Text bold={!given} dimColor={!!given}>{fit(oneLine(agent.type), 20)}</Text>}
1003            <Text dimColor={!!given}>
1004              {given && agent.type === DEFAULT_TYPE
1005                ? fit(oneLine(agent.description), Math.max(8, W - 42 - cols(given)))
1006                : `(${fit(oneLine(agent.description), Math.max(8, W - 42 - cols(agent.type) - cols(given)))})`}
1007            </Text>
1008          </Box>,
1009          <Box>
1010            {clock(agent)}
1011            {agent.status !== 'running' ? null : armed === agent.id ? (
1012              <Button key="stop" label="Confirm stop" variant="primary" onPress={() => update($, confirmStop, () => null).then(() => stopAgent($, agent))} />
1013            ) : (
1014              <Button key="stop" label="■ Stop" dimColor onPress={arm} />
1015            )}
1016          </Box>,
1017        ),
1018        <Text wrap="truncate-end">
1019          <Text dimColor>
1020            {`  ⎿  ${[l.word, act[agent.id] ? uses(act[agent.id]!.tools) : '', runsOn(agent), viewed === agent.id ? 'also in the main view' : ''].filter(Boolean).join(' · ')}`}
1021          </Text>
1022          {alertOf(agent) ? <Text color="error">{` · ${alertOf(agent)}`}</Text> : null}
1023        </Text>,
1024        <Text dimColor wrap="truncate-end">
1025          {used
1026            ? `     ${fmtTokens(used.input)} in (${fmtTokens(used.cached)} cached) · ${fmtTokens(used.output)} out · context ${fmtTokens(used.context)} · ${used.requests} request${used.requests === 1 ? '' : 's'}`
1027            : '     no token figures yet'}
1028        </Text>,
1029        // Live: the newest blocks, anchored to the bottom (the oldest cut at the top). Scrolled back: from the
1030        // block at `top` down, so a block taller than the pane shows its start and new blocks do not move it.
1031        <Box height={rows - 5} flexDirection="column" justifyContent={top === null ? 'flex-end' : 'flex-start'} overflow="hidden">
1032          {shown.length ? shown.map(block) : <Text dimColor>Its conversation is not available yet.</Text>}
1033        </Box>,
1034        <Text> </Text>,
1035        <Box width={W} justifyContent="space-between">
1036          <Box>
1037            <Button key="older" label="▲" hotkey="k" dimColor onPress={() => update($, scrollTop, v => Math.max(0, (v ?? blocks.length - 1) - 1))} />
1038            <Button
1039              key="newer"
1040              label="▼"
1041              hotkey="j"
1042              dimColor
1043              onPress={() => update($, scrollTop, v => (v === null || v + 1 >= blocks.length - 1 ? null : v + 1))}
1044            />
1045            {top === null ? (
1046              <Text color="success"> ● live</Text>
1047            ) : (
1048              <Button key="live" label={newer > 0 ? `⇣ ${newer} newer` : '⇣ live'} onPress={() => update($, scrollTop, () => null)} />
1049            )}
1050          </Box>
1051          {hint(e.props.isFocused ? 'b back · k/j scroll · esc to prompt ' : 'click here for keys b/k/j ')}
1052        </Box>,
1053      ])
1054    }
1055
1056    // The list, a roster: an agent a row, its columns lined up down the pane: its mark, the name it was given
1057    // (where any agent has one), its task, what it is doing now or how it ended, its tool count, its tokens
1058    // and its clock. Finished agents keep their rows under the running ones.
1059    const shown = visibleAgents(all, cfg.keepFinished)
1060    const named = nameColumn(shown)
1061    const model = sharedModel(shown)
1062    const hidingDone = await read($, hideDone)
1063    const { running, finished } = arrange(shown, hidingDone)
1064    const goTo = async (to: string) => {
1065      await showList($)
1066      await update($, viewing, () => to)
1067      await openPane($, 'conversation', true)
1068    }
1069    // A row's name column, `width` wide: the name (cut where the pane is too narrow for it) opens the agent as
1070    // its task does; a row without one keeps the column's width.
1071    const nameCell = (a: Agent, width: number) => {
1072      const name = width > 2 ? fit(givenName(a), width - 2) : ''
1073      return [
1074        name ? <Button key={`name-${a.id}`} label={name} plain hover={{ color: 'claude', underline: true }} onPress={() => goTo(a.id)} /> : null,
1075        width ? <Text>{' '.repeat(width - cols(name))}</Text> : null,
1076      ]
1077    }
1078    // Model loops no agent claims, recently active: a workflow's agents, compaction and memory forks.
1079    const quiet = Object.entries(await read($, loops)).filter(([, x]) => now - x.lastSeen < LOOP_SHOWN_MS)
1080    const loopsLine = quiet.length
1081      ? `⏺ ${quiet.length} other model loop${quiet.length === 1 ? '' : 's'} (workflow agents or forks) · ${quiet.reduce((n, [, x]) => n + x.requests, 0)} requests · ${fmtTokens(quiet.reduce((n, [lid]) => n + (tok[lid]?.input ?? 0), 0))} in`
1082      : ''
1083    // The latest batch on one time axis (two agents or more, where there is room), and once it has ended,
1084    // what it came to.
1085    const batch = latestBatch(all)
1086    const receipt = batchReceipt(batch, act, tok)
1087    const receiptLine = receipt ? (
1088      <Text wrap="truncate-end">
1089        <Text color={receipt.failed ? 'error' : 'success'}>{receipt.failed ? '✗ ' : '✓ '}</Text>
1090        <Text dimColor>{receipt.text}</Text>
1091      </Text>
1092    ) : null
1093    // A lane is labelled by the agent's name alone, the list above saying what each one does; without a name,
1094    // by its task. The label column is as wide as the widest label, within the old bounds; under a list with
1095    // a name column it is that column, so a lane's bar starts where the tasks do.
1096    const laneLabel = (a: Agent) => givenName(a) || oneLine(a.description || a.type)
1097    const lanesMax = Math.min(MAX_LANES, Math.floor(rows / 3))
1098    const nameCols = Math.min(30, Math.floor(W * 0.38), Math.max(0, ...batch.slice(-lanesMax).map(a => cols(laneLabel(a)))) + (shown.some(a => givenName(a)) ? 1 : 0))
1099    const barCols = W - 2 - nameCols - 1 - DUR_COLS
1100    const onAxis = batch.length >= 2 && barCols >= 8 ? batch.slice(-lanesMax) : []
1101    const lanes: Lane[] = onAxis.map(a => {
1102      const l = look(a.status)
1103      const name = fit(laneLabel(a), nameCols)
1104      return {
1105        name: name + ' '.repeat(Math.max(0, nameCols - cols(name))), mark: l.mark, color: l.color ?? '', dim: !!l.dim,
1106        from: a.firstSeen, to: a.status === 'running' ? null : (a.endedAt ?? a.firstSeen),
1107      }
1108    })
1109    const axisTitle = ` timeline${onAxis.length < batch.length ? ` · latest ${onAxis.length} of ${batch.length}` : ''} `
1110    const axis = lanes.length ? (
1111      <Box flexDirection="column" flexShrink={0} marginTop={1}>
1112        <Text dimColor wrap="truncate-end">{`──${axisTitle}${'─'.repeat(Math.max(0, W - 2 - axisTitle.length))}`}</Text>
1113        {Client ? (
1114          <Client key="lanes" module="./lanes.tsx" props={{ lanes, now, bar: barCols }} />
1115        ) : (
1116          laneRows(lanes, now, barCols).map((g, i) => (
1117            <Text wrap="truncate-end">
1118              <Text color={lanes[i]!.color || undefined} dimColor={lanes[i]!.dim}>{`${lanes[i]!.mark} `}</Text>
1119              <Text>{lanes[i]!.name}</Text>
1120              <Text dimColor>{` ${'·'.repeat(g.before)}`}</Text>
1121              <Text color={lanes[i]!.color || undefined} dimColor={lanes[i]!.dim}>{'━'.repeat(g.bar)}</Text>
1122              <Text dimColor>{`${'·'.repeat(g.after)}${fmtDuration(g.ms).padStart(DUR_COLS)}`}</Text>
1123            </Text>
1124          ))
1125        )}
1126        {receiptLine}
1127      </Box>
1128    ) : receiptLine ? (
1129      <Box flexShrink={0} marginTop={1}>
1130        {receiptLine}
1131      </Box>
1132    ) : null
1133    const heading = (
1134      <Text>
1135        <Text color="claude">✻ </Text>
1136        <Text bold>Agents</Text>
1137        {model ? <Text dimColor>{` · ${model}`}</Text> : null}
1138      </Text>
1139    )
1140    // One agent's row, `width` wide, in the columns `c`: its cells add up to `width`, and the row is one line
1141    // high whatever a terminal makes of a character this module miscounts. `beneath`: the docked list, where a
1142    // row may have a line under it: what a running agent is doing where there is no column for it, and what
1143    // cannot wait (the main view shows it; a response was cut). The summary above the prompt has no rows to spare.
1144    type Note = { text: string; color?: string }
1145    const rosterRow = (a: Agent, depth: number, width: number, c: ReturnType<typeof rosterColumns>, beneath: boolean): RenderChildren[] => {
1146      const pad = ' '.repeat(Math.min(2 * depth, c.task - MIN_TASK + 2)) // a child, indented while the task keeps 6 columns
1147      const l = look(a.status)
1148      const live = a.status === 'running'
1149      const doing = act[a.id]
1150      const used = tok[a.id]
1151      const room = c.task - cols(pad)
1152      const task = fit(taskOf(a, room), room - 2)
1153      const own = model ? '' : runsOn(a) // said on the row only when the agents' models differ
1154      const alert: Note[] = alertOf(a) ? [{ text: alertOf(a), color: 'error' }] : []
1155      const flags: Note[] = [...(viewed === a.id ? [{ text: '◂ main view', color: 'claude' }] : []), ...alert]
1156      // What it is doing now, or how it ended.
1157      const state: Note = live
1158        ? { text: [own, doing?.text ?? ''].filter(Boolean).join(' · ') }
1159        : { text: [l.word.toLowerCase(), own].filter(Boolean).join(' · '), ...(a.status === 'failed' && { color: 'error' }) }
1160      const cut = (notes: readonly Note[], max: number) => {
1161        let left = max
1162        return notes.filter(n => n.text).flatMap((n, i) => {
1163          const text = left > 3 ? fit(`${i ? ' · ' : ''}${n.text}`, left) : ''
1164          left -= cols(text)
1165          return text ? [<Text color={n.color} dimColor={!n.color}>{text}</Text>] : []
1166        }).concat(<Text>{' '.repeat(Math.max(0, left))}</Text>)
1167      }
1168      const n = doing?.tools ?? 0
1169      const count = n ? `${n > 9_999 ? `${Math.floor(n / 1000)}k` : n} tool${n === 1 ? '' : 's'}` : ''
1170      const countCell = n ? `${count.slice(0, count.indexOf(' ')).padStart(c.tools - 6)} ${n === 1 ? 'tool ' : 'tools'}` : ''
1171      const lines: RenderChildren[] = [
1172        <Box key={`row-${a.id}`} width={width} height={1} overflow="hidden">
1173          {markOf(a, pad)}
1174          {nameCell(a, c.name)}
1175          <Button key={`agent-${a.id}`} label={task} plain dimColor={!!givenName(a) || !live} hover={{ color: 'claude', underline: true }} onPress={() => goTo(a.id)} />
1176          <Text>{' '.repeat(Math.max(0, room - cols(task)))}</Text>
1177          {c.doing ? cut(beneath ? [state] : [state, ...alert], c.doing - 1).concat(<Text> </Text>) : null}
1178          {c.tools ? <Text dimColor>{countCell.padStart(c.tools)}</Text> : null}
1179          {c.tokens ? <Text dimColor>{(used ? fmtTokens(used.input + used.output) : '').padStart(c.tokens)}</Text> : null}
1180          <Box width={CLOCK_COLS} flexShrink={0} justifyContent="flex-end">
1181            {clock(a)}
1182          </Box>
1183        </Box>,
1184      ]
1185      if (beneath) {
1186        const said: Note[] = [...flags, ...(!c.doing && live ? [{ text: [state.text, n > 1 || !state.text ? count : ''].filter(Boolean).join(' · ') }] : [])].filter(x => x.text)
1187        const lead = `${pad}  ⎿ `
1188        if (said.length) lines.push(<Box key={`doing-${a.id}`} width={width} height={1} overflow="hidden"><Text dimColor>{lead}</Text>{cut(said, width - cols(lead))}</Box>)
1189      }
1190      return lines
1191    }
1192    const columnsFor = (rowsOf: readonly { agent: Agent; depth: number }[], width: number, beneath: boolean) =>
1193      rosterColumns(width, cols(named(shown[0] ?? ({} as Agent))), Math.max(0, ...rowsOf.map(r => cols(taskOf(r.agent, width)) + 2 * r.depth)), beneath)
1194    const counts = <Box key="counts">
1195      <Text dimColor>{runningCount ? `${runningCount} running` : ''}</Text>
1196      <Text dimColor>{runningCount && doneCount ? ' · ' : ''}</Text>
1197      {doneCount ? (
1198        <Button key="toggle-done" label={hidingDone ? `${doneCount} done (show)` : `${doneCount} done`} plain dimColor hover={{ color: 'claude', underline: true }} onPress={() => update($, hideDone, v => !v)} />
1199      ) : null}
1200    </Box>
hooks/draw.ts 93 lines
1// Pure name-drawing logic: no engine calls, so it is unit-tested with plain node.
2
3export type Category = {
4  key: string;
5  subagent_types: string[];
6  keywords: string[];
7  names: string[];
8  /** Agent types the user's names file routes here (`for`): they win before keywords are read. */
9  first?: string[];
10  /** Names the user added to this pool, when it also holds others: they get FAVOUR of the draws. */
11  favoured?: string[];
12};
13export type Pool = { categories: Category[] };
14
15/** Roles that say nothing about the task: their description's keywords decide first. */
16export const GENERIC_ROLES = ['general-purpose', 'worker'];
17/** What the Agent tool runs when `subagent_type` is omitted. */
18const DEFAULT_ROLE = 'general-purpose';
19const FALLBACK = 'default';
20/**
21 * The share of draws that go to the user's added names while one is free. Without it, two
22 * added names in a pool of thirty-odd would name about one agent in eighteen.
23 */
24export const FAVOUR = 0.5;
25
26function byType(pool: Pool, role: string): string | undefined {
27  const r = role.trim().toLowerCase();
28  return pool.categories.find(c => c.subagent_types.some(t => t.toLowerCase() === r))?.key;
29}
30
31function byKeyword(pool: Pool, text: string): string | undefined {
32  const t = text.toLowerCase();
33  let best: string | undefined;
34  let bestHits = 0;
35  for (const c of pool.categories) {
36    const hits = c.keywords.filter(k => t.includes(k)).length;
37    if (hits > bestHits) { best = c.key; bestHits = hits; } // strict >: ties keep the earlier category
38  }
39  return best;
40}
41
42/**
43 * Category for one dispatch: a valid `theme` pins it; then a user's set naming this role;
44 * otherwise a specific role wins, a generic role defers to description keywords, and the
45 * default pool catches the rest.
46 * Mirrors the retired Python package's resolve_for_hook (named-subagents 0.7.2).
47 */
48export function categoryFor(pool: Pool, subagentType?: string, description?: string, theme = 'auto'): string {
49  if (theme !== 'auto' && pool.categories.some(c => c.key === theme)) return theme;
50  const role = subagentType ?? DEFAULT_ROLE;
51  const r = role.trim().toLowerCase();
52  const mine = pool.categories.find(c => c.first?.some(t => t.toLowerCase() === r));
53  if (mine) return mine.key; // the user said which agents this pool is for
54  const fromType = byType(pool, role);
55  const fromTask = description ? byKeyword(pool, description) : undefined;
56  if (GENERIC_ROLES.includes(role.trim().toLowerCase())) return fromTask ?? fromType ?? FALLBACK;
57  return fromType ?? fromTask ?? FALLBACK;
58}
59
60/**
61 * A name from `category` not in `taken` (case-insensitive). The pool's favoured names get
62 * FAVOUR of the draws while one is free. An exhausted category spills to the default pool,
63 * then to any free name; with all taken, a numbered suffix.
64 */
65export function drawName(pool: Pool, category: string, taken: Iterable<string>, rand: () => number): string {
66  const used = new Set([...taken].map(n => n.toLowerCase()));
67  const free = (names: string[]) => names.filter(n => !used.has(n.toLowerCase()));
68  const home = pool.categories.find(c => c.key === category) ?? pool.categories.find(c => c.key === FALLBACK);
69  const mine = free(home?.favoured ?? []);
70  if (mine.length && rand() < FAVOUR) return mine[Math.min(mine.length - 1, Math.floor(rand() * mine.length))]!;
71  const tiers = [home?.names ?? [], pool.categories.find(c => c.key === FALLBACK)?.names ?? [], pool.categories.flatMap(c => c.names)];
72  for (const tier of tiers) {
73    const f = free(tier);
74    const pick = f[Math.min(f.length - 1, Math.floor(rand() * f.length))];
75    if (pick !== undefined) return pick;
76  }
77  const base = home?.names[0] ?? pool.categories.find(c => c.names.length)?.names[0] ?? 'Agent';
78  for (let n = 2; ; n++) if (!used.has(`${base}-${n}`.toLowerCase())) return `${base}-${n}`;
79}
80
81export type PickInput = {
82  subagentType?: string;
83  description?: string;
84  theme?: string;
85  taken: Iterable<string>;
86  rand: () => number;
87};
88
89export function pickName(pool: Pool, i: PickInput): { name: string; category: string } {
90  const category = categoryFor(pool, i.subagentType, i.description, i.theme);
91  return { name: drawName(pool, category, i.taken, i.rand), category };
92}
93
hooks/pool.ts 740 lines
1// GENERATED by scripts/gen_pool.mjs from registry.json. Do not edit.
2import type { Pool } from './draw.ts';
3
4export const POOL: Pool = {
5  "categories": [
6    {
7      "key": "explore",
8      "subagent_types": [
9        "Explore",
10        "explorer"
11      ],
12      "keywords": [
13        "explore",
14        "search",
15        "map",
16        "survey",
17        "scout",
18        "locate",
19        "find",
20        "inventory",
21        "codebase",
22        "where is",
23        "trace files",
24        "discover"
25      ],
26      "names": [
27        "Magellan",
28        "Shackleton",
29        "Amundsen",
30        "Cook",
31        "Polo",
32        "Columbus",
33        "Livingstone",
34        "Hillary",
35        "Earhart",
36        "IbnBattuta",
37        "ZhengHe",
38        "Lewis",
39        "Clark",
40        "Drake",
41        "Hudson",
42        "Cabot",
43        "DaGama",
44        "Nansen",
45        "Scott",
46        "Boone",
47        "Sacagawea",
48        "Tenzing",
49        "Cousteau",
50        "Gagarin",
51        "Heyerdahl",
52        "Bering",
53        "Tasman",
54        "Xuanzang",
55        "Pytheas",
56        "Frobisher"
57      ]
58    },
59    {
60      "key": "code",
61      "subagent_types": [
62        "worker",
63        "general-purpose",
64        "coder",
65        "code"
66      ],
67      "keywords": [
68        "implement",
69        "build",
70        "feature",
71        "write code",
72        "function",
73        "endpoint",
74        "add",
75        "create",
76        "develop",
77        "patch",
78        "class",
79        "method",
80        "module"
81      ],
82      "names": [
83        "Turing",
84        "Dijkstra",
85        "Knuth",
86        "Hopper",
87        "Ada",
88        "Torvalds",
89        "Ritchie",
90        "Thompson",
91        "Kernighan",
92        "BernersLee",
93        "Stallman",
94        "Carmack",
95        "Guido",
96        "Stroustrup",
97        "Backus",
98        "McCarthy",
99        "AlanKay",
100        "Engelbart",
101        "Wozniak",
102        "Liskov",
103        "Lamport",
104        "Cerf",
105        "AlKhwarizmi",
106        "Babbage",
107        "Matz",
108        "Gosling",
109        "Hejlsberg",
110        "Aho",
111        "Ullman",
112        "Hamilton",
113        "Perlis",
114        "Iverson",
115        "Hoare"
116      ]
117    },
118    {
119      "key": "research",
120      "subagent_types": [
121        "research-subagent",
122        "researcher"
123      ],
124      "keywords": [
125        "research",
126        "investigate",
127        "compare",
128        "landscape",
129        "gather",
130        "sources",
131        "literature",
132        "cite",
133        "benchmark",
134        "study",
135        "survey the",
136        "state of the art"
137      ],
138      "names": [
139        "Darwin",
140        "Newton",
141        "Einstein",
142        "Curie",
143        "Feynman",
144        "Faraday",
145        "Bohr",
146        "Heisenberg",
147        "Rosalind",
148        "Mendel",
149        "Pasteur",
150        "Galileo",
151        "Kepler",
152        "Hubble",
153        "Hawking",
154        "Maxwell",
155        "Planck",
156        "Rutherford",
157        "Fermi",
158        "Dirac",
159        "Schrodinger",
160        "Volta",
161        "Kelvin",
162        "Herschel",
163        "Meitner",
164        "Goodall",
165        "Sagan",
166        "Chandrasekhar",
167        "McClintock",
168        "Pauling",
169        "Avicenna",
170        "Copernicus"
171      ]
172    },
173    {
174      "key": "reflect",
175      "subagent_types": [
176        "architect",
177        "philosopher"
178      ],
179      "keywords": [
180        "architecture",
181        "rationale",
182        "why is",
183        "why was",
184        "why did",
185        "why does",
186        "why do",
187        "philosophy",
188        "meta",
189        "reflect",
190        "principles",
191        "tradeoff",
192        "conceptual",
193        "wonder",
194        "intent",
195        "design decision",
196        "design choice",
197        "abstraction",
198        "chosen",
199        "first principles",
200        "ponder"
201      ],
202      "names": [
203        "Socrates",
204        "Plato",
205        "Aristotle",
206        "Kant",
207        "Hegel",
208        "Nietzsche",
209        "Descartes",
210        "Hume",
211        "Spinoza",
212        "Leibniz",
213        "Wittgenstein",
214        "Heidegger",
215        "Sartre",
216        "Confucius",
217        "Laozi",
218        "Aquinas",
219        "Locke",
220        "Rousseau",
221        "Camus",
222        "Arendt",
223        "Beauvoir",
224        "Aurelius",
225        "Epictetus",
226        "Seneca",
227        "Kierkegaard",
228        "Schopenhauer",
229        "Hypatia",
230        "Averroes",
231        "Zhuangzi",
232        "Montaigne",
233        "Weil"
234      ]
235    },
236    {
237      "key": "debug",
238      "subagent_types": [
239        "debugger"
240      ],
241      "keywords": [
242        "debug",
243        "root cause",
244        "bug",
245        "failure",
246        "crash",
247        "stack trace",
248        "reproduce",
249        "diagnose",
250        "failing",
251        "regression cause",
252        "why does it break"
253      ],
254      "names": [
255        "Holmes",
256        "Poirot",
257        "Marple",
258        "Dupin",
259        "Columbo",
260        "Spade",
261        "Marlowe",
262        "Nancy",
263        "Magnum",
264        "Clouseau",
265        "Wimsey",
266        "Maigret",
267        "Wolfe",
268        "Vance",
269        "Fandorin",
270        "Morse",
271        "Rebus",
272        "Bosch",
273        "Scarpetta",
274        "Archer",
275        "Hammer",
276        "Millhone",
277        "Goren",
278        "Brenner"
279      ]
280    },
281    {
282      "key": "test",
283      "subagent_types": [
284        "qa",
285        "tester",
286        "qa-expert"
287      ],
288      "keywords": [
289        "test",
290        "qa",
291        "edge case",
292        "fuzz",
293        "coverage",
294        "assertion",
295        "adversarial",
296        "break it",
297        "eval",
298        "regression",
299        "property test",
300        "boundary"
301      ],
302      "names": [
303        "Loki",
304        "Anansi",
305        "Coyote",
306        "Hermes",
307        "Prometheus",
308        "Sisyphus",
309        "Eris",
310        "Puck",
311        "Reynard",
312        "Kokopelli",
313        "Maui",
314        "Susanoo",
315        "Wukong",
316        "Eulenspiegel",
317        "Brer",
318        "Legba",
319        "Nasreddin",
320        "Iktomi",
321        "Kitsune",
322        "Tanuki",
323        "Pan",
324        "Set",
325        "Veles",
326        "Baubo"
327      ]
328    },
329    {
330      "key": "review",
331      "subagent_types": [
332        "code-reviewer",
333        "reviewer"
334      ],
335      "keywords": [
336        "review",
337        "critique",
338        "correctness",
339        "feedback",
340        "approve",
341        "verdict",
342        "scrutinize",
343        "pull request",
344        "pr review",
345        "sign off",
346        "assess quality"
347      ],
348      "names": [
349        "Solomon",
350        "Solon",
351        "Hammurabi",
352        "Justinian",
353        "Marshall",
354        "Cardozo",
355        "Brandeis",
356        "Ginsburg",
357        "Thurgood",
358        "OConnor",
359        "Scalia",
360        "Story",
361        "Blackstone",
362        "Bracton",
363        "Mansfield",
364        "Denning",
365        "Warren",
366        "Portia",
367        "Bao",
368        "Draco",
369        "Lycurgus",
370        "Ulpian",
371        "Papinian",
372        "Sotomayor"
373      ]
374    },
375    {
376      "key": "security",
377      "subagent_types": [
378        "security-auditor",
379        "penetration-tester"
380      ],
381      "keywords": [
382        "security",
383        "audit",
384        "vulnerability",
385        "threat",
386        "pentest",
387        "exploit",
388        "cve",
389        "auth",
390        "injection",
391        "guardrail",
392        "harden",
393        "secrets",
394        "ssrf"
395      ],
396      "names": [
397        "Argus",
398        "Cerberus",
399        "Heimdall",
400        "Horus",
401        "Bastet",
402        "Aegis",
403        "Fafnir",
404        "Ladon",
405        "Talos",
406        "Anubis",
407        "Tyr",
408        "Athena",
409        "Garuda",
410        "Hachiman",
411        "Sekhmet",
412        "Fenrir",
413        "Vidar",
414        "Perseus",
415        "Durga",
416        "Nemesis",
417        "Golem",
418        "Shu"
419      ]
420    },
421    {
422      "key": "design",
423      "subagent_types": [
424        "ui-designer",
425        "frontend-developer",
426        "ux-researcher"
427      ],
428      "keywords": [
429        "ui",
430        "ux",
431        "frontend",
432        "design",
433        "css",
434        "layout",
435        "component",
436        "visual",
437        "style",
438        "accessibility",
439        "wireframe",
440        "mockup",
441        "typography",
442        "palette"
443      ],
444      "names": [
445        "DaVinci",
446        "Michelangelo",
447        "Rembrandt",
448        "Monet",
449        "Picasso",
450        "Kahlo",
451        "Hokusai",
452        "Klimt",
453        "Matisse",
454        "OKeeffe",
455        "Vermeer",
456        "Caravaggio",
457        "Rothko",
458        "Kandinsky",
459        "Rams",
460        "PaulRand",
461        "Eames",
462        "Vignelli",
463        "SaulBass",
464        "Mucha",
465        "Escher",
466        "Gaudi",
467        "Basquiat",
468        "Warhol"
469      ]
470    },
471    {
472      "key": "data",
473      "subagent_types": [
474        "data-scientist",
475        "data-engineer",
476        "machine-learning-engineer"
477      ],
478      "keywords": [
479        "data",
480        "sql",
481        "query",
482        "analytics",
483        "statistics",
484        "ml",
485        "training",
486        "dataframe",
487        "dataset",
488        "visualization",
489        "chart",
490        "model accuracy",
491        "distribution"
492      ],
493      "names": [
494        "Gauss",
495        "Euler",
496        "Riemann",
497        "Fermat",
498        "Fibonacci",
499        "Bayes",
500        "Fisher",
501        "Tukey",
502        "Pascal",
503        "Laplace",
504        "Kolmogorov",
505        "Erdos",
506        "Hilbert",
507        "Cantor",
508        "Noether",
509        "Ramanujan",
510        "Nightingale",
511        "Gosset",
512        "Galois",
513        "Poincare",
514        "Bernoulli",
515        "Lagrange",
516        "Markov",
517        "Nash",
518        "Aryabhata",
519        "Brahmagupta",
520        "Shannon",
521        "Boole",
522        "Fourier",
523        "Cauchy"
524      ]
525    },
526    {
527      "key": "orchestrate",
528      "subagent_types": [
529        "Plan",
530        "orchestrator",
531        "coordinator",
532        "multi-agent-coordinator"
533      ],
534      "keywords": [
535        "orchestrate",
536        "coordinate",
537        "plan",
538        "break down",
539        "distribute",
540        "delegate",
541        "strategy",
542        "roadmap",
543        "milestone",
544        "schedule",
545        "sequence the work"
546      ],
547      "names": [
548        "SunTzu",
549        "Napoleon",
550        "Hannibal",
551        "Caesar",
552        "Alexander",
553        "Zhukov",
554        "Patton",
555        "Rommel",
556        "Scipio",
557        "Nelson",
558        "Wellington",
559        "Khalid",
560        "Saladin",
561        "Genghis",
562        "Belisarius",
563        "Themistocles",
564        "Leonidas",
565        "Pericles",
566        "Clausewitz",
567        "Marlborough",
568        "Timur",
569        "Joan",
570        "Boudica",
571        "Kutuzov",
572        "Nimitz",
573        "Cyrus",
574        "Subutai"
575      ]
576    },
577    {
578      "key": "docs",
579      "subagent_types": [
580        "documentation-engineer",
581        "docs",
582        "technical-writer"
583      ],
584      "keywords": [
585        "docs",
586        "documentation",
587        "readme",
588        "write up",
589        "write the",
590        "explain",
591        "guide",
592        "tutorial",
593        "changelog",
594        "comment",
595        "narrative",
596        "prose",
597        "manual",
598        "api reference",
599        "reference for",
600        "document the"
601      ],
602      "names": [
603        "Shakespeare",
604        "Tolstoy",
605        "Austen",
606        "Orwell",
607        "Hemingway",
608        "Borges",
609        "Woolf",
610        "Twain",
611        "Kafka",
612        "Dickens",
613        "Homer",
614        "Dante",
615        "Cervantes",
616        "Chekhov",
617        "Dostoevsky",
618        "Proust",
619        "Joyce",
620        "Melville",
621        "Poe",
622        "Angelou",
623        "Morrison",
624        "Marquez",
625        "Neruda",
626        "Rumi",
627        "Basho",
628        "Murasaki",
629        "Ovid",
630        "Virgil",
631        "Sappho",
632        "Achebe",
633        "Tagore"
634      ]
635    },
636    {
637      "key": "build",
638      "subagent_types": [
639        "devops-engineer",
640        "refactoring-specialist",
641        "build-engineer",
642        "performance-engineer"
643      ],
644      "keywords": [
645        "infra",
646        "deploy",
647        "devops",
648        "docker",
649        "kubernetes",
650        "ci",
651        "optimize",
652        "performance",
653        "refactor",
654        "legacy",
655        "migrate",
656        "terraform",
657        "cloud",
658        "cache",
659        "throughput"
660      ],
661      "names": [
662        "Tesla",
663        "Edison",
664        "Brunel",
665        "Eiffel",
666        "Watt",
667        "Stephenson",
668        "Roebling",
669        "Telford",
670        "Ford",
671        "Diesel",
672        "Otto",
673        "Benz",
674        "Daimler",
675        "Whitney",
676        "Arkwright",
677        "Nasmyth",
678        "Bessemer",
679        "Sikorsky",
680        "Whittle",
681        "Carver",
682        "GrahamBell",
683        "Marconi",
684        "Wright",
685        "Fulton",
686        "Gutenberg",
687        "Archimedes",
688        "Heron",
689        "Lamarr"
690      ]
691    },
692    {
693      "key": "default",
694      "subagent_types": [
695        "default",
696        "claude",
697        "general"
698      ],
699      "keywords": [],
700      "names": [
701        "Orion",
702        "Vega",
703        "Rigel",
704        "Altair",
705        "Sirius",
706        "Polaris",
707        "Antares",
708        "Cygnus",
709        "Aquila",
710        "Andromeda",
711        "Cassiopeia",
712        "Pyxis",
713        "Carina",
714        "Vela",
715        "Corvus",
716        "Auriga",
717        "Bootes",
718        "Hydra",
719        "Phoenix",
720        "Cepheus",
721        "Lynx",
722        "Pavo",
723        "Tucana",
724        "Grus",
725        "Ara",
726        "Norma",
727        "Fornax",
728        "Mensa",
729        "Volans",
730        "Dorado",
731        "Sculptor",
732        "Caelum",
733        "Pictor",
734        "Antlia",
735        "Crux"
736      ]
737    }
738  ]
739};
740
hooks/custom.ts 386 lines
1// Custom names: pure logic, no engine calls, so it is unit-tested with plain node.
2//
3// A names file (and the install screen's `names` list) is parsed into a Custom, and one or
4// more Customs are layered onto the built-in pool: user file, then project file, then the
5// install screen. `/names` edits the user file through the functions at the bottom.
6
7import type { Category, Pool } from './draw.ts';
8
9/** The Agent tool's own rule for `name`; a call carrying anything else is refused. */
10export const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
11const MAX_LEN = 60; // leaves room for the "-2" suffix of an exhausted pool
12const FALLBACK = 'default';
13
14/**
15 * Makes a typed name one the Agent tool accepts: accents dropped, apostrophes dropped,
16 * words joined ("Mary Shelley" -> "MaryShelley"). Undefined when nothing usable is left.
17 */
18export function cleanName(raw: string): string | undefined {
19  if (/[\u0000-\u001f\u007f-\u009f]/.test(raw.trim())) return undefined; // no name holds a control character; not worth salvaging
20  const parts = raw.normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/['’`]/g, '')
21    .split(/[^A-Za-z0-9_-]+/).filter(p => p !== '');
22  const joined = parts.map((p, i) => (i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1))).join('')
23    .replace(/^[_-]+/, '').slice(0, MAX_LEN);
24  return NAME_RE.test(joined) ? joined : undefined;
25}
26
27export type CustomSet = { names: string[]; for: string[]; keywords: string[]; replace: boolean };
28export type Custom = {
29  /** Leave out everything layered before this (the built-in names included). */
30  only: boolean;
31  /** The pool every agent draws from; absent or "auto" picks by agent type and task. */
32  use?: string;
33  /** Names that join every pool except the sets the files define themselves. */
34  names: string[];
35  /** Names never drawn. */
36  remove: string[];
37  /** Old name -> new name. */
38  rename: Record<string, string>;
39  /** A new pool, or more names (or with `replace`, other names) for a built-in one. */
40  sets: Record<string, CustomSet>;
41};
42export type Parsed = { custom: Custom; problems: string[]; cleaned: string[] };
43
44export const emptyCustom = (): Custom => ({ only: false, names: [], remove: [], rename: {}, sets: {} });
45
46const lower = (s: string) => s.toLowerCase();
47function uniq(names: string[]): string[] {
48  const seen = new Set<string>();
49  return names.filter(n => !seen.has(lower(n)) && !!seen.add(lower(n)));
50}
51/**
52 * A names file is untrusted input (a project's arrives with the repository), so what is read
53 * from one is bounded: the file's size, how many names, sets and routes it may hold, and how
54 * many problems are kept to report.
55 */
56export const LIMITS = { fileChars: 256 * 1024, names: 2000, sets: 100, routes: 200, problems: 20 } as const;
57
58/**
59 * Text safe to show in a terminal: control characters (escape sequences among them) and the
60 * characters that reorder text become "?". Newlines stay.
61 */
62export const printable = (s: string) => s.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '?');
63
64/** A bad entry as a problem quotes it: short, on one line, and printable, whatever it held. */
65const quote = (v: unknown) => {
66  const t = printable(typeof v === 'string' ? v : JSON.stringify(v) ?? String(v)).replace(/\s+/g, ' ').trim();
67  return `"${t.length > 40 ? `${t.slice(0, 40)}…` : t}"`;
68};
69/** At most LIMITS.problems lines, each printable; the rest are counted. */
70function bounded(lines: string[], label: string, what: string): string[] {
71  const safe = lines.slice(0, LIMITS.problems).map(t => printable(t).replace(/\n/g, ' '));
72  return lines.length > LIMITS.problems ? [...safe, `${label}: and ${lines.length - LIMITS.problems} more ${what}`] : safe;
73}
74const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
75
76/** A list of names from a JSON array of strings or one comma-separated string (or a list of those). */
77function nameList(v: unknown, where: string, p: Parsed): string[] {
78  let raw = typeof v === 'string' ? v.split(',') : Array.isArray(v) ? v.flatMap(x => (typeof x === 'string' ? x.split(',') : [x])) : undefined;
79  if (raw === undefined) { p.problems.push(`${where} must be a list of names`); return []; }
80  if (raw.length > LIMITS.names) { p.problems.push(`${where}: only the first ${LIMITS.names} names are read`); raw = raw.slice(0, LIMITS.names); }
81  const out: string[] = [];
82  for (const r of raw) {
83    if (typeof r !== 'string') { p.problems.push(`${where}: ${quote(r)} is not a name`); continue; }
84    if (r.trim() === '') continue;
85    const c = cleanName(r);
86    if (c === undefined) { p.problems.push(`${where}: ${quote(r)} cannot be a name (letters, digits, _ and - only)`); continue; }
87    if (c !== r.trim()) p.cleaned.push(`${quote(r)} -> ${c}`);
88    out.push(c);
89  }
90  return uniq(out);
91}
92
93function textList(v: unknown, where: string, p: Parsed): string[] {
94  if (!Array.isArray(v) || v.some(x => typeof x !== 'string')) { p.problems.push(`${where} must be a list of strings`); return []; }
95  if (v.length > LIMITS.routes) p.problems.push(`${where}: only the first ${LIMITS.routes} entries are read`);
96  // Shown by /names and matched against agent types and task text: one printable line each.
97  return (v as string[]).slice(0, LIMITS.routes).map(s => printable(s).replace(/\s+/g, ' ').trim().slice(0, 64)).filter(s => s !== '');
98}
99
100/** A Custom from an already-parsed JSON value; what is wrong is listed, the rest is kept. */
101export function readCustom(value: unknown, rawLabel: string): Parsed {
102  const label = printable(rawLabel).replace(/\s+/g, ' ').slice(0, 120);
103  const p = readAll(value, label);
104  return { custom: p.custom, problems: bounded(p.problems, label, 'problems'), cleaned: bounded(p.cleaned, label, 'adjusted names') };
105}
106
107function readAll(value: unknown, label: string): Parsed {
108  const p: Parsed = { custom: emptyCustom(), problems: [], cleaned: [] };
109  if (!isRecord(value)) { p.problems.push(`${label}: expected a JSON object`); return p; }
110  const c = p.custom;
111  for (const [key, v] of Object.entries(value)) {
112    const where = `${label}: ${key}`;
113    if (key === 'only') { if (typeof v === 'boolean') c.only = v; else p.problems.push(`${where} must be true or false`); }
114    else if (key === 'use') { if (typeof v === 'string' && NAME_RE.test(v.trim())) c.use = v.trim(); else p.problems.push(`${where} must be a pool's name`); }
115    else if (key === 'names') c.names = nameList(v, where, p);
116    else if (key === 'remove') c.remove = nameList(v, where, p);
117    else if (key === 'rename') {
118      if (!isRecord(v)) { p.problems.push(`${where} must map old names to new ones`); continue; }
119      const pairs = Object.entries(v);
120      if (pairs.length > LIMITS.names) p.problems.push(`${where}: only the first ${LIMITS.names} names are read`);
121      for (const [from, to] of pairs.slice(0, LIMITS.names)) {
122        const [f] = nameList([from], where, p);
123        const [t] = typeof to === 'string' ? nameList([to], where, p) : [];
124        if (f !== undefined && t !== undefined) c.rename[f] = t;
125        else if (typeof to !== 'string') p.problems.push(`${where}: ${quote(from)} needs a new name`);
126      }
127    } else if (key === 'sets') {
128      if (!isRecord(v)) { p.problems.push(`${where} must map a set's name to its names`); continue; }
129      for (const [setKey, s] of Object.entries(v)) {
130        if (Object.keys(c.sets).length >= LIMITS.sets) { p.problems.push(`${where}: only the first ${LIMITS.sets} sets are read`); break; }
131        const at = `${label}: sets.${setKey.slice(0, 40)}`; // used only once the key has passed NAME_RE
132        if (!NAME_RE.test(setKey)) { p.problems.push(`${label}: sets: ${quote(setKey)} cannot name a set (letters, digits, _ and - only)`); continue; }
133        const set: CustomSet = { names: [], for: [], keywords: [], replace: false };
134        if (Array.isArray(s) || typeof s === 'string') set.names = nameList(s, at, p); // shorthand: just the names
135        else if (isRecord(s)) {
136          for (const [k, sv] of Object.entries(s)) {
137            if (k === 'names') set.names = nameList(sv, `${at}.names`, p);
138            else if (k === 'for') set.for = textList(sv, `${at}.for`, p);
139            else if (k === 'keywords') set.keywords = textList(sv, `${at}.keywords`, p).map(lower);
140            else if (k === 'replace') { if (typeof sv === 'boolean') set.replace = sv; else p.problems.push(`${at}.replace must be true or false`); }
141            else p.problems.push(`${at}: unknown key ${quote(k)} ignored`);
142          }
143        } else { p.problems.push(`${at} must be a list of names or an object`); continue; }
144        c.sets[setKey] = set;
145      }
146    } else p.problems.push(`${label}: unknown key ${quote(key)} ignored`);
147  }
148  return p;
149}
150
151/** A Custom from a names file's text. Invalid JSON is one problem and an empty Custom. */
152export function parseCustom(text: string, label: string): Parsed {
153  if (text.length > LIMITS.fileChars) return tooLarge(label);
154  let value: unknown;
155  try { value = JSON.parse(text); } catch (err) {
156    // The engine's message quotes the head of what it could not parse, and a project's file may be a
157    // symlink to anything: only the position is kept, never the text.
158    const where = /position (\d+)/.exec(String(err instanceof Error ? err.message : err))?.[1];
159    return { custom: emptyCustom(), problems: [`${printable(label).slice(0, 120)}: not valid JSON${where ? ` (at character ${where})` : ''}`], cleaned: [] };
160  }
161  return readCustom(value, label);
162}
163
164const tooLarge = (label: string): Parsed => ({ custom: emptyCustom(), problems: [`${printable(label).slice(0, 120)}: too large (over ${LIMITS.fileChars / 1024} KB); it was not read`], cleaned: [] });
165
166/** The file's text for a Custom: only the keys that say something. */
167export function serializeCustom(c: Custom): string {
168  const out: Record<string, unknown> = {};
169  if (c.only) out.only = true;
170  if (c.use !== undefined) out.use = c.use;
171  if (c.names.length) out.names = c.names;
172  if (c.remove.length) out.remove = c.remove;
173  if (Object.keys(c.rename).length) out.rename = c.rename;
174  const sets: Record<string, unknown> = {};
175  for (const [k, s] of Object.entries(c.sets)) {
176    sets[k] = { names: s.names, ...(s.for.length ? { for: s.for } : {}), ...(s.keywords.length ? { keywords: s.keywords } : {}), ...(s.replace ? { replace: true } : {}) };
177  }
178  if (Object.keys(sets).length) out.sets = sets;
179  return JSON.stringify(out, null, 2) + '\n';
180}
181
182export type Layer = { label: string; custom: Custom };
183export type Applied = { pool: Pool; use?: string; problems: string[] };
184
185const findCat = (cats: Category[], key: string) => cats.find(c => lower(c.key) === lower(key));
186
187/**
188 * The pool after each layer in turn. Within a layer: `only` empties what came before, sets
189 * are defined (new ones go first; a set's `for` wins for those agent types), flat names join
190 * every pool that is not a file-defined set, then `rename` and then `remove` apply to everything.
191 * Names added to a pool that keeps other names are marked `favoured`, so they are drawn often.
192 * A result with no names at all falls back to `base`, with a problem saying so.
193 */
194export function applyCustom(base: Pool, layers: Layer[]): Applied {
195  let cats: Category[] = base.categories.map(c => ({ ...c, subagent_types: [...c.subagent_types], keywords: [...c.keywords], names: [...c.names] }));
196  const own = new Set<string>(); // keys of the sets the layers created
197  const problems: string[] = [];
198  let use: string | undefined;
199  let useFrom = '';
200  for (const { label, custom: c } of layers) {
201    if (c.only) { cats = []; own.clear(); }
202    const fresh: Category[] = [];
203    for (const [key, s] of Object.entries(c.sets)) {
204      let cat = findCat(cats, key) ?? findCat(fresh, key);
205      if (!cat) { cat = { key, subagent_types: [], keywords: [], names: [] }; fresh.push(cat); own.add(lower(key)); }
206      if (s.for.length) cat.first = [...new Set([...s.for, ...(cat.first ?? [])])];
207      if (!own.has(lower(cat.key))) cat.favoured = uniq([...(cat.favoured ?? []), ...s.names]); // added to a built-in pool
208      cat.names = uniq([...(s.replace ? [] : cat.names), ...s.names]);
209      cat.subagent_types = [...new Set([...s.for, ...cat.subagent_types])];
210      cat.keywords = [...new Set([...s.keywords, ...cat.keywords])];
211    }
212    cats = [...fresh, ...cats];
213    if (c.names.length) {
214      let targets = cats.filter(k => !own.has(lower(k.key)));
215      if (!targets.length) {
216        let d = findCat(cats, FALLBACK);
217        if (!d) { d = { key: FALLBACK, subagent_types: [], keywords: [], names: [] }; cats.push(d); }
218        targets = [d];
219      }
220      for (const t of targets) { t.names = uniq([...t.names, ...c.names]); t.favoured = uniq([...(t.favoured ?? []), ...c.names]); }
221    }
222    const renames = Object.entries(c.rename);
223    if (renames.length) {
224      const to = new Map(renames.map(([f, t]) => [lower(f), t]));
225      for (const k of cats) { k.names = uniq(k.names.map(n => to.get(lower(n)) ?? n)); if (k.favoured) k.favoured = uniq(k.favoured.map(n => to.get(lower(n)) ?? n)); }
226    }
227    if (c.remove.length) { // after rename, so a name can be hidden by what it is called now
228      const gone = new Set(c.remove.map(lower));
229      for (const k of cats) k.names = k.names.filter(n => !gone.has(lower(n)));
230    }
231    if (c.use !== undefined) { use = lower(c.use) === 'auto' ? undefined : c.use; useFrom = label; }
232  }
233  for (const k of cats) { // favoured: the added names still in the pool, and only where the pool holds others too
234    const has = new Set(k.names.map(lower));
235    const kept = (k.favoured ?? []).filter(n => has.has(lower(n)));
236    if (kept.length && kept.length < k.names.length) k.favoured = kept; else delete k.favoured;
237  }
238  if (!cats.some(k => k.names.length)) {
239    problems.push('no names are left after your changes; using the built-in names');
240    return { pool: base, problems };
241  }
242  if (use !== undefined) {
243    const hit = findCat(cats, use);
244    if (hit) use = hit.key; else { problems.push(`${useFrom}: use names no pool called "${use}"; picking by agent type instead`); use = undefined; }
245  }
246  return { pool: { categories: cats }, use, problems };
247}
248
249// ---- /names: edits to the user's file ----
250
251/** Splits a command's arguments on spaces and commas; quotes keep a name with spaces whole. */
252export function tokenize(args: string): string[] {
253  const out: string[] = [];
254  for (const m of args.matchAll(/"([^"]*)"|'([^']*)'|([^\s,]+)/g)) {
255    const t = (m[1] ?? m[2] ?? m[3] ?? '').trim();
256    if (t !== '') out.push(t);
257  }
258  return out;
259}
260
261export type Edit = { custom: Custom; lines: string[]; changed: boolean };
262
263function cleanAll(raw: string[], lines: string[]): string[] {
264  const p: Parsed = { custom: emptyCustom(), problems: [], cleaned: [] };
265  const names = nameList(raw, 'name', p);
266  if (p.cleaned.length) lines.push(`Adjusted to fit the name rule: ${p.cleaned.join(', ')}`);
267  for (const pr of p.problems) lines.push(pr.replace(/^name: /, 'Skipped: '));
268  return names;
269}
270
271export const USAGE = [
272  '/names                             what is set, and where the files are',
273  '/names add Ripley "Mary Shelley"   more names, for every built-in pool',
274  '/names remove Sisyphus             never draw this name',
275  '/names rename Turing Alan          call one name something else',
276  '/names set pirates Kidd Bonny      define a set (a pool of your own); same name again replaces it',
277  '/names unset pirates               drop a set',
278  '/names use pirates | auto          draw every agent from one pool, or go back to picking by task',
279  '/names only on | off               leave out the built-in names',
280  '/names import <file>               merge a names file, a JSON list, or one name per line',
281  '/names reset                       clear your file (a .bak copy is kept)',
282].join('\n');
283
284/**
285 * One editing verb applied to the user's Custom. `builtin` is the pool the file is layered
286 * on: `use` may name its pools, and `remove` hides its names. Unknown verbs and bad
287 * arguments change nothing.
288 */
289export function editCustom(before: Custom, verb: string, argv: string[], builtin: Pool): Edit {
290  const pools = builtin.categories.map(k => k.key);
291  const c: Custom = { ...before, names: [...before.names], remove: [...before.remove], rename: { ...before.rename }, sets: Object.fromEntries(Object.entries(before.sets).map(([k, s]) => [k, { ...s, names: [...s.names] }])) };
292  const lines: string[] = [];
293  const same = (lines2: string[]): Edit => ({ custom: before, lines: lines2, changed: false });
294  if (verb === 'add') {
295    const names = cleanAll(argv, lines);
296    if (!names.length) return same([...lines, 'Usage: /names add Ripley "Mary Shelley"']);
297    const added = names.filter(n => !c.names.some(x => lower(x) === lower(n)));
298    c.names = uniq([...c.names, ...names]);
299    c.remove = c.remove.filter(r => !names.some(n => lower(n) === lower(r)));
300    lines.push(added.length ? `Added ${added.join(', ')}.` : 'Already there.');
301    if (!c.only) lines.push('Your names get about half of the draws while one is free; /names only on leaves the built-in names out.');
302  } else if (verb === 'remove') {
303    const names = cleanAll(argv, lines);
304    if (!names.length) return same([...lines, 'Usage: /names remove Sisyphus']);
305    const isBuiltin = (n: string) => builtin.categories.some(k => k.names.some(x => lower(x) === lower(n)));
306    for (const n of names) {
307      const mine = c.names.some(x => lower(x) === lower(n)) || Object.values(c.sets).some(s => s.names.some(x => lower(x) === lower(n)));
308      c.names = c.names.filter(x => lower(x) !== lower(n));
309      for (const s of Object.values(c.sets)) s.names = s.names.filter(x => lower(x) !== lower(n));
310      // A name that exists only because of a rename: undo the rename and hide what it was.
311      const was = Object.keys(c.rename).filter(f => lower(c.rename[f] ?? '') === lower(n));
312      for (const f of was) { delete c.rename[f]; c.remove = uniq([...c.remove, f]); }
313      if (mine) lines.push(`Removed ${n} from your names.`);
314      if (was.length) lines.push(`${n} was your name for ${was.join(', ')}; that will not be drawn.`);
315      if (isBuiltin(n) || (!mine && !was.length)) { c.remove = uniq([...c.remove, n]); lines.push(`${n} will not be drawn.`); }
316    }
317  } else if (verb === 'rename') {
318    const names = cleanAll(argv, lines);
319    const [from, to] = names;
320    if (from === undefined || to === undefined || names.length !== 2) return same([...lines, 'Usage: /names rename Turing Alan']);
321    c.rename[from] = to;
322    lines.push(`${from} is now ${to}.`);
323  } else if (verb === 'set') {
324    const [typed, ...rest] = argv;
325    if (typed === undefined || !NAME_RE.test(typed)) return same(["Usage: /names set pirates Kidd Bonny   (the set's name takes letters, digits, _ and - only)"]);
326    const key = Object.keys(c.sets).find(k => lower(k) === lower(typed)) ?? typed; // "Pirates" is the set "pirates"
327    const names = cleanAll(rest, lines);
328    if (!names.length) return same([...lines, `Usage: /names set ${key} Kidd Bonny`]);
329    const old = c.sets[key];
330    c.sets[key] = { names, for: old?.for ?? [], keywords: old?.keywords ?? [], replace: old?.replace ?? false };
331    lines.push(`Set ${key}: ${names.join(', ')}.`, `Use it for every agent with /names use ${key}.`);
332  } else if (verb === 'unset') {
333    const [key] = argv;
334    const hit = key === undefined ? undefined : Object.keys(c.sets).find(k => lower(k) === lower(key));
335    if (hit === undefined) return same([`No set called ${key ?? '(nothing)'} in your file.`]);
336    delete c.sets[hit];
337    if (c.use !== undefined && lower(c.use) === lower(hit)) delete c.use;
338    lines.push(`Dropped the set ${hit}.`);
339  } else if (verb === 'use') {
340    const [key] = argv;
341    if (key === undefined) return same(['Usage: /names use pirates   or   /names use auto']);
342    if (lower(key) === 'auto') { delete c.use; lines.push('Pools are picked by agent type and task again.'); }
343    else {
344      const hit = [...Object.keys(c.sets), ...pools].find(k => lower(k) === lower(key));
345      if (hit === undefined) return same([`No pool called ${key}. Pools: ${[...Object.keys(c.sets), ...pools].join(', ')}.`]);
346      c.use = hit;
347      lines.push(`Every agent now draws from ${hit}.`);
348    }
349  } else if (verb === 'only') {
350    const [flag] = argv.map(lower);
351    if (flag !== 'on' && flag !== 'off') return same(['Usage: /names only on   or   /names only off']);
352    c.only = flag === 'on';
353    lines.push(c.only ? 'The built-in names are left out; only yours are drawn.' : 'The built-in names are back.');
354  } else return same([USAGE]);
355  return { custom: c, lines, changed: serializeCustom(c) !== serializeCustom(before) };
356}
357
358/** `incoming` merged into `into`: lists are joined, and so is a set of the same name. */
359export function mergeCustom(into: Custom, incoming: Custom): Custom {
360  const sets: Record<string, CustomSet> = Object.fromEntries(Object.entries(into.sets).map(([k, s]) => [k, { ...s }]));
361  for (const [k, s] of Object.entries(incoming.sets)) {
362    const key = Object.keys(sets).find(x => lower(x) === lower(k)) ?? k;
363    const old = Object.hasOwn(sets, key) ? sets[key] : undefined; // "constructor" is a set only when the file has it
364    sets[key] = old === undefined ? { ...s } : {
365      names: uniq([...old.names, ...s.names]), for: [...new Set([...old.for, ...s.for])],
366      keywords: [...new Set([...old.keywords, ...s.keywords])], replace: old.replace || s.replace,
367    };
368  }
369  return {
370    only: into.only || incoming.only,
371    ...(incoming.use ?? into.use) !== undefined ? { use: incoming.use ?? into.use } : {},
372    names: uniq([...into.names, ...incoming.names]),
373    remove: uniq([...into.remove, ...incoming.remove]),
374    rename: { ...into.rename, ...incoming.rename },
375    sets,
376  };
377}
378
379/** What an imported file holds: a names file, a JSON list of names, or one name per line. */
380export function parseImport(text: string, label: string): Parsed {
381  if (text.length > LIMITS.fileChars) return tooLarge(label);
382  let value: unknown;
383  try { value = JSON.parse(text); } catch { value = text.split(/\r?\n/); } // audit-allow: fail-loud — not JSON means a plain list, one name per line
384  return readCustom(Array.isArray(value) ? { names: value } : value, label);
385}
386
hooks/status.ts 21 lines
1// The plugin has one status line and two things to say on it: naming's alarm (a name that
2// did not reach its agent, a names file that is wrong) and the pane's count of running
3// agents while it is not on screen. Each half sets its own part and writes the line this
4// returns, so neither wipes the other's. With both to say, the count leads and the alarm
5// follows it: an alarm can stand for a whole session (a names file that stays wrong).
6//
7// These are module variables, so a reload forgets a standing alarm; the pane's first sync
8// after it (or, with the pane switched off, the session start) then writes the line afresh. A names-file problem is alarmed again at the next
9// dispatch; a "drew X but…" alarm is not.
10
11let alarm: string | undefined;
12let running: string | undefined;
13
14const line = () => (alarm !== undefined && running !== undefined ? `${running} · ${alarm}` : alarm ?? running);
15
16/** Naming's alarm, or undefined to clear it; returns the line to show. */
17export const withAlarm = (text: string | undefined) => { alarm = text; return line(); };
18
19/** The pane's running count, or undefined for none; returns the line to show. */
20export const withRunning = (text: string | undefined) => { running = text; return line(); };
21
hooks/time.ts 26 lines
1// From agentpane 1.1.4 by Anji Xu (https://github.com/xuanji86/claude-agentpane, MIT: see LICENSE-agentpane); unchanged.
2// Time as the pane draws it, pure: shared by the hooks module and the surface modules.
3
4// As Claude Code's own status lines read a duration: "8s", "1m 13s", "1h 2m".
5export const fmtDuration = (ms: number) => {
6  const s = Math.max(0, Math.floor(ms / 1000)), m = Math.floor(s / 60), h = Math.floor(m / 60)
7  return h ? `${h}h ${m % 60}m` : m ? `${m}m ${s % 60}s` : `${s}s`
8}
9
10// One agent on the time axis: when it started, and when it ended (null while it runs).
11export type Lane = { name: string; mark: string; color: string; dim: boolean; from: number; to: number | null }
12
13// Each lane's bar on one axis `width` cells wide, from the first start to now (or, all ended, to the last
14// end): the cells before it, of it (at least one) and after it; and how long it ran.
15export const laneRows = (lanes: readonly Lane[], now: number, width: number) => {
16  if (!lanes.length) return []
17  const start = Math.min(...lanes.map(l => l.from))
18  const end = lanes.some(l => l.to === null) ? now : Math.max(...lanes.map(l => l.to ?? now))
19  const at = (t: number) => Math.round(((t - start) / Math.max(1, end - start)) * width)
20  return lanes.map(l => {
21    const from = Math.min(width - 1, Math.max(0, at(l.from)))
22    const to = Math.min(width, Math.max(from + 1, at(l.to ?? end)))
23    return { before: from, bar: to - from, after: width - to, ms: (l.to ?? end) - l.from }
24  })
25}
26
hooks/live.tsx 59 lines
1// From agentpane 1.1.4 by Anji Xu (https://github.com/xuanji86/claude-agentpane, MIT: see LICENSE-agentpane); `markOnly` added.
2// A running agent's live line, drawn on the surface's own frame clock so only this region redraws, not the
3// pane: Claude Code's spinner glyph, a shimmer across "Running…", and a clock. `since` and `now` come from
4// the hooks module's clock; between its redraws the clock counts on from them. `clockOnly` draws the clock
5// alone, `markOnly` the spinner alone (a roster row's mark). Surfaces with no Client (VS Code, mobile) get
6// the hooks module's static line instead.
7import type { ClientModule } from 'claude-code'
8
9import { fmtDuration } from './time'
10
11type Props = { since: number; now: number; pad: string; detail: string; alert: string; motion: boolean; clockOnly: boolean; markOnly?: boolean }
12type Ref = { base: number; lastNow: number; ms: number; step: number }
13type State = { ref: Ref }
14
15const STEP_MS = 120 // a frame of the shimmer; the spinner turns every other frame
16const SPIN = ['·', '✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳', '✢']
17const WORD = 'Running…'
18
19const Live: ClientModule<Props, State> = (props, surface) => {
20  const { Text } = surface.elements
21  const ref = surface.state?.ref ?? { base: 0, lastNow: -1, ms: 0, step: 0 }
22  if (props.now !== ref.lastNow) {
23    // A fresh reading from the hooks module: count on from it.
24    ref.lastNow = props.now
25    ref.base = props.now - props.since
26    ref.ms = 0
27  }
28  if (surface.state === undefined) {
29    const step = props.motion && !props.clockOnly ? STEP_MS : 1000
30    surface.setState({ ref })
31    surface.every(step, () => {
32      ref.ms += step
33      ref.step += 1
34      surface.setState({ ref })
35    })
36  }
37  const time = fmtDuration(ref.base + ref.ms)
38  if (props.clockOnly) return <Text dimColor>{time}</Text>
39
40  const glyph = props.motion ? (SPIN[Math.floor(ref.step / 2) % SPIN.length] ?? '✻') : '✻'
41  if (props.markOnly) return <Text color="claude">{`${props.pad}${glyph} `}</Text>
42  const at = props.motion ? (ref.step % (WORD.length + 6)) - 3 : -9
43  const lit = (i: number) => Math.abs(i - at) <= 1
44  const chars = [...WORD]
45  return (
46    <Text wrap="truncate-end">
47      <Text color="claude">{`${props.pad}  ${glyph} `}</Text>
48      {chars.map((ch, i) => (
49        <Text color={lit(i) ? 'claudeShimmer' : 'claude'}>{ch}</Text>
50      ))}
51      <Text dimColor>{` (${[time, props.detail].filter(Boolean).join(' · ')}`}</Text>
52      {props.alert ? <Text color="error">{` · ${props.alert}`}</Text> : null}
53      <Text dimColor>)</Text>
54    </Text>
55  )
56}
57
58export default Live
59
hooks/lanes.tsx 53 lines
1// From agentpane 1.1.4 by Anji Xu (https://github.com/xuanji86/claude-agentpane, MIT: see LICENSE-agentpane); unchanged.
2// The agents on one time axis, a row each: its bar from when it started to when it ended, and how long it
3// ran. Drawn on the surface's own clock, so a running agent's bar grows and its clock counts without the
4// pane redrawing. Surfaces with no Client (VS Code, mobile) get the hooks module's still copy instead.
5import type { ClientModule } from 'claude-code'
6
7import { fmtDuration, laneRows } from './time'
8import type { Lane } from './time'
9
10type Props = { lanes: Lane[]; now: number; bar: number }
11type Ref = { lastNow: number; ms: number; live: boolean }
12type State = { ref: Ref }
13
14const STEP_MS = 1000
15
16const Lanes: ClientModule<Props, State> = (props, surface) => {
17  const { Box, Text } = surface.elements
18  const ref = surface.state?.ref ?? { lastNow: -1, ms: 0, live: false }
19  if (props.now !== ref.lastNow) {
20    // A fresh reading from the hooks module: count on from it.
21    ref.lastNow = props.now
22    ref.ms = 0
23  }
24  ref.live = props.lanes.some(l => l.to === null)
25  if (surface.state === undefined) {
26    surface.setState({ ref })
27    surface.every(STEP_MS, () => {
28      if (!ref.live) return // all ended: the axis stands still
29      ref.ms += STEP_MS
30      surface.setState({ ref })
31    })
32  }
33  const geo = laneRows(props.lanes, props.now + ref.ms, props.bar)
34  return (
35    <Box flexDirection="column">
36      {props.lanes.map((l, i) => {
37        const g = geo[i]!
38        return (
39          <Text wrap="truncate-end">
40            <Text color={l.color || undefined} dimColor={l.dim}>{`${l.mark} `}</Text>
41            <Text>{l.name}</Text>
42            <Text dimColor>{` ${'·'.repeat(g.before)}`}</Text>
43            <Text color={l.color || undefined} dimColor={l.dim}>{'━'.repeat(g.bar)}</Text>
44            <Text dimColor>{`${'·'.repeat(g.after)}${fmtDuration(g.ms).padStart(8)}`}</Text>
45          </Text>
46        )
47      })}
48    </Box>
49  )
50}
51
52export default Lanes
53
types/index.d.ts 69 lines
1// From agentpane 1.1.4 by Anji Xu (https://github.com/xuanji86/claude-agentpane, MIT: see LICENSE-agentpane); the state is declared under this mod's name.
2/** An agent as the pane lists it: `$.agent.list()`'s row, with when the pane first saw it and when it ended. */
3export type AgentpaneAgent = {
4  id: string
5  description: string
6  type: string
7  /** `running`, `completed`, `failed`, `killed`, or another of the engine's task statuses. */
8  status: string
9  name?: string
10  parentId?: string
11  /** When the pane first listed it. */
12  firstSeen: number
13  /** Whether the pane saw it running, so `firstSeen` is when it started (to within a second). */
14  seenRunning: boolean
15  /** When the pane first saw it done; absent while it runs, or when it was done before the pane saw it. */
16  endedAt?: number
17  /** The model it runs on: its spawn's report, then the model each of its requests names; absent until either is seen. */
18  model?: string
19  /** How hard its requests ask it to think ('low' … 'max', or a budget number); absent until a request names one. */
20  effort?: string | number
21}
22
23/** The tokens an agent's responses used, summed as the API reported them. */
24export type AgentpaneTokens = {
25  /** Input over all its requests: uncached, cache-read and cache-written together. */
26  input: number
27  /** The part of `input` the prompt cache served. */
28  cached: number
29  output: number
30  /** Its latest request's input and output: how full its context is. */
31  context: number
32  requests: number
33  /** Responses cut off at the output limit (`max_tokens`). */
34  truncated?: number
35}
36
37/** A model loop no listed agent claims: a workflow's agent, a compaction or memory fork. */
38export type AgentpaneLoop = { firstSeen: number; lastSeen: number; requests: number }
39
40/** What an agent is doing: its latest tool call, and how many it has made. */
41export type AgentpaneActivity = { text: string; tools: number }
42
43declare module 'claude-code' {
44  interface PluginState {
45    'named-subagents-mod': {
46      agents: AgentpaneAgent[]
47      activity: Record<string, AgentpaneActivity>
48      tokens: Record<string, AgentpaneTokens>
49      loops: Record<string, AgentpaneLoop>
50      /** The agent whose conversation the pane shows; null for the list. */
51      viewing: string | null
52      /** The block scrolled back to, drawn at the top; null follows the conversation live. */
53      scrollTop: number | null
54      /** Bumped when the conversation in view grows, so the pane draws it again. */
55      rev: number
56      /** The agent whose Stop was pressed once: the next press stops it. */
57      confirmStop: string | null
58      /** The list shows running agents only. */
59      hideDone: boolean
60      /** The tool-call groups opened in the conversation in view, by their first call's id. */
61      expanded: string[]
62      /** Folded away by its handle: the pane is closed and a tab above the prompt reopens it. */
63      folded: boolean
64      /** Whether that tab shows: folded, with agents running or just finished. */
65      tab: boolean
66    }
67  }
68}
69