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…

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.
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
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 }
}
}
| Key | Meaning |
|---|---|
names | Extra 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). |
rename | Old name to new name. |
remove | Names never drawn. Applied after rename, so a renamed name is hidden by its new name. |
sets | A 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. |
use | One 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. |
only | true 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.
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).
/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).b goes back, k and j step through it, Esc gives the keyboard back.◂ 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.✓ 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.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.
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:
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.~/.claude/named-subagents.json) and its .bak, on /names edits only. Nothing else, and nothing is kept after the session beyond that file.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.
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.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.$.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.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.✻ 2 agents running · named-subagents: your names file: not valid JSON ….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.
| Field | Default | Meaning |
|---|---|---|
theme | auto | auto, or a category key (code, explore, debug, …) to always use that pool |
enabled | true | false turns naming off without uninstalling (and /names with it) |
names | empty | Your names, separated by commas; they join every built-in pool |
only_custom | false | true leaves the built-in names out |
pane | true | false turns the pane off: names only (see below) |
autoOpen | true | open the pane when an agent starts; off, /roster opens it |
foldAfter | 10 | seconds after the last agent finishes before the pane folds; 0 keeps it open |
motion | true | animate the spinner; off, it stands still and the clocks still run |
toasts | true | a toast when agents finish |
keepFinished | 8 | finished agents the list keeps (1–30) |
statusLine | true | count 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.
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.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.
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).
$.agent.list(); the transcript's launch list and finish notices quote the description only.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).probes/mods-names-proof/README.md). Uninstall the Python plugin./roster./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./roster cannot be registered the mod says so in a toast, and the pane still opens by itself and counts agents.agentpane plugin also installed there are two panes with the same id. Uninstall it: claude plugin uninstall agentpane@claude-agentpane.waiting row.CLAUDE_CONFIG_DIR.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.
MIT. See LICENSE; the pane's files are under LICENSE-agentpane, also MIT.
hooks/index.ts 18 lines1// 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};
18hooks/names.ts 309 lines1// 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};
309hooks/pane.tsx 1285 lines1// 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 lines1// 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}
93hooks/pool.ts 740 lines1// 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};
740hooks/custom.ts 386 lines1// 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}
386hooks/status.ts 21 lines1// 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(); };
21hooks/time.ts 26 lines1// 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}
26hooks/live.tsx 59 lines1// 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
59hooks/lanes.tsx 53 lines1// 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
53types/index.d.ts 69 lines1// 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