Throwaway probe: just-in-time callsign registration inside agent.spawn, hidden by agent.offer.

Retired (2026-10-06). Replaced by named-subagents-mod, a Claude Code mod that needs Claude Code 2.1.287 or later and no Python. 0.7.2 is the last Python release; this repository is archived and gets no further fixes.
Distinct, themed, non-repeating names for parallel Claude Code subagents — a userspace port of Codex's per-instance nickname_candidates.
<img src="https://raw.githubusercontent.com/Bobby-cell-commits/named-subagents/master/assets/demo.gif" alt="A real Claude Code 2.1.283 session fanning out three subagents; the task tree shows them as Backus, Chekhov and Bosch" width="760">
Fan out several subagents in Claude Code and the task tree shows each one by its type. This plugin gives every instance its own name, themed to the kind of task and never repeated across runs. (Recorded from a real session; the banner and status line are cropped out.)
claude plugin marketplace add Bobby-cell-commits/named-subagents
claude plugin install named-subagents@named-subagents
Needs python3 (3.8+) on PATH. New sessions name every subagent, for any agent type.
The plugin runs four hooks:
PreToolUse sets the Agent tool's name field (shown in the tree) and the <emoji> Name · task label (quoted in finish notices). Two live agents never share a name, because name is also the address SendMessage routes by; other local sessions' titles are skipped too. A name the model chose is left alone.SubagentStart tells the agent its name and asks it to open its report with [Name].SubagentStop frees the name by agent ID, so a resumed agent keeps its name.Stop fails loud: if Claude Code did not record the name the hook set (for example, a future version stops honoring name), you see a named-subagents: … notice at the end of the turn.It adds nothing to the prompt: no agent files, no extra agent-list entries. NAMED_SUBAGENTS_HOOK_DISABLE=1 turns it off.
If you registered hooks earlier with named-subagents hook install, run named-subagents hook uninstall; while that install is present the plugin's hooks stand down so the two never name one dispatch.
Known limits. name is an Agent-tool input that is not in the published schema, so an update could drop it (the Stop check exists for that). If you refuse an Agent permission prompt and a same-type dispatch starts within 30 seconds, that agent may be told the refused name; SubagentStop repairs the record and shows a mix-up notice. Details and live verification: CHANGELOG.
pip install named-subagents
named-subagents hook install # name mode: the plugin's four hooks, in ~/.claude/settings.json
named-subagents hook status
hook install --context-only registers the older namer instead (deprecated, removal planned for 0.8): the agent learns its name, but the tree does not show it. With the plugin enabled, a context-only install turns the tree names off, because the plugin steps aside for every event these hooks cover. hook install --project . scopes either to one project; hook uninstall removes them. install/uninstall back up settings.json, refuse to touch malformed JSON, and only add or remove their own entries. Hooks load at session start, so open a new session afterwards. Install one runtime's hooks per machine (the ports lock state differently).
| Env var | Effect |
|---|---|
NAMED_SUBAGENTS_HOOK_DISABLE=1 | pause the hooks without uninstalling |
NAMED_SUBAGENTS_LEDGER | ledger path (default ~/.local/state/named-subagents/hook-ledger.json) |
NAMED_SUBAGENTS_QUEUE_DIR | dispatch-queue dir (default ~/.local/state/named-subagents/queue/) |
NAMED_SUBAGENTS_HOOK_BIO=1 | add the figure's one-line bio to the identity block |
NAMED_SUBAGENTS_PLUGIN_FORCE=1 | run the plugin's hooks even next to a settings.json install |
395 names in 14 categories, each globally unique, each with a one-line bio:
| Category | Task shape | Theme | e.g. |
|---|---|---|---|
explore | map / search a codebase | Explorers & navigators | Magellan, Shackleton |
code | implement features | Programmers & computing pioneers | Turing, Hopper |
research | external info gathering | Scientists | Curie, Feynman |
reflect | design rationale | Philosophers | Socrates, Kant |
debug | root-cause hunting | Detectives | Holmes, Poirot |
test | edge cases, adversarial | Tricksters | Loki, Anansi |
review | critique, verdict | Judges & jurists | Solomon, Ginsburg |
security | audit, threat model | Guardians & sentinels | Argus, Heimdall |
design | UI / UX / visual | Artists & designers | DaVinci, Rams |
data | analysis, stats, ML | Mathematicians | Gauss, Noether |
orchestrate | plan, coordinate | Strategists | SunTzu, Napoleon |
docs | technical writing | Writers | Orwell, Borges |
build | infra / refactor / perf | Engineers & inventors | Tesla, Brunel |
default | catch-all | Stars | Orion, Vega |
A task maps to a category by explicit category > subagent_type > task keywords > default. The keyword layer is a heuristic; pass category= or role= when the theme must be exact.
Non-repeat. allocate() draws in a deterministic md5-seeded order, records used names in a ledger, and skips them next time. When a pool runs out it starts a new generation (Magellan·2, …). A name is never reused unless you release it. Same (category, ledger state) gives the same result, so re-runs are safe.
<img src="https://raw.githubusercontent.com/Bobby-cell-commits/named-subagents/master/assets/library-demo.gif" alt="examples/demo.py: four fan-out rounds with themed, non-repeating names and the ledger summary" width="640">
pip install named-subagents # Python 3.8+, zero dependencies
from named_subagents import Registry, Ledger, plan_fanout
reg = Registry.load()
ledger = Ledger(".named-subagents-ledger.json")
plan = plan_fanout(["map the auth module", "map the billing module"],
reg, ledger=ledger, role="Explore")
for a in plan:
print(a.emoji, a.nickname, "—", a.bio) # a.agent_kwargs() -> Agent-tool payload
The generated prompt asks the agent to open with [Name]; attribute(nickname, report) repairs a missing or wrong prefix when all you have is the report text.
named-subagents resolve --task "audit auth" --explain # which theme, and why
named-subagents allocate --category reflect --count 3
named-subagents assign --role Explore --task "map the router" --count 4 \
--ledger .ledger.json [--format agent|labels|workflow|swarm|table]
named-subagents assign --task "audit the release" --pin security=Argus # stable identity
named-subagents release --category explore --name Hudson --ledger .ledger.json # recycle
named-subagents retire --category explore --name Columbus --ledger .ledger.json # never again
named-subagents bio Heimdall
named-subagents stats --ledger .ledger.json
named-subagents doctor # self-checks; --json for machines
named-subagents init # scaffold a config
--avoid-installed keeps names disjoint from your .claude/agents names.--format emits snippets for Workflow scripts, swarm YAML, or plain labels.doctor checks registry integrity, ledger health, pins and version strings, and self-tests the hooks./named-fanout skill: cp -r skill/named-fanout ~/.claude/skills/.Config comes from --config PATH, $NAMED_SUBAGENTS_CONFIG, or ~/.config/named-subagents/config.json:
{ "pins": { "security": "Argus" },
"categories": { "starships": { "theme": "Star systems", "emoji": "🚀",
"keywords": ["fleet"], "names": ["Enterprise", "Rocinante"] } },
"extend": { "explore": { "names": ["Kupe"] } } }
New keys add categories, existing keys replace them, extend appends. Custom names are validated on load because they end up inside agent prompts. A project-local ./.named-subagents.json is not loaded unless you pass --cwd-config (or set NAMED_SUBAGENTS_CWD_CONFIG=1), since a cloned repo controls it. See SECURITY.md.
python3 tests/test_named_subagents.py && python3 tests/test_hook.py
python3 tests/test_name_mode.py && python3 tests/test_roster.py
CI runs the suites on Python 3.8/3.12/3.13, plus ruff and coverage. See CONTRIBUTING.md for the ground rules and docs/RELEASING.md for releases. The npm package and its JavaScript port were retired in 0.7.1. Design notes live in docs/research/; docs/COMMUNITY.md surveys the rest of the ecosystem, which names agents by role, not instance.
hooks/jit.ts 134 lines1// mods-jit — throwaway. Design (c): register a callsign just in time inside agent.spawn,
2// keep it out of the model's agent listing via agent.offer, dispatch to it.
3// mode.txt (plugin root) selects: show = no offer filter (baseline listing cost)
4// hide = isOffered:false for every JIT name, always
5// gate = isOffered:false unless a spawn for that name is in flight
6// call = isOffered:false unless an Agent tool.call is in progress
7// (tool.call runs before the dispatch-time offer batch,
8// never during the per-turn listing)
9// tight = isOffered:true only for the ONE type an in-progress Agent
10// call reserved (picked in tool.call, keyed by tool_use_id)
11// pre-<m>[-N] = same offer rule, but the names are registered up front at session.start (JIT inside
12// spawn is refused: the call's dispatchable set is fixed before the hook runs).
13// Witness: every fire appends a JSONL line to markers.jsonl via $.fs.
14import type { Register, EngineInterface } from 'claude-code';
15type Json = Record<string, unknown>;
16const POOL = ['Hopper', 'Lovelace', 'Noether', 'Shackleton', 'Tereshkova', 'Amundsen', 'Kovalevskaya', 'Ibn_Battuta'];
17const jit = new Set<string>(); // full registered type names
18const inflight = new Set<string>(); // types a spawn is dispatching right now
19let calls = 0; // Agent tool.calls in progress
20const reserved = new Map<string, string>(); // tool_use_id -> type (tight mode)
21let cursor = 0;
22let n = 0; let mode: string | undefined;
23let chain: Promise<void> = Promise.resolve();
24function mark($: EngineInterface, hook: string, data: Json): Promise<void> {
25 const path = `${$.plugin.root}/markers.jsonl`;
26 const line = JSON.stringify({ ts: new Date().toISOString(), hook, ...data }) + '\n';
27 chain = chain.then(async () => {
28 const prev = (await $.fs.exists(path)) ? await $.fs.read(path) : '';
29 await $.fs.write(path, prev + line);
30 }).catch(() => undefined); // audit-allow: fail-loud — probe telemetry
31 return chain;
32}
33async function getMode($: EngineInterface): Promise<string> {
34 if (mode === undefined) {
35 const p = `${$.plugin.root}/mode.txt`;
36 mode = (await $.fs.exists(p)) ? String(await $.fs.read(p)).trim() : 'show';
37 }
38 return mode;
39}
40async function reg($: EngineInterface, name: string): Promise<string | undefined> {
41 // @ts-expect-error — $.agent.register is in the 2.1.283 binary, not the 2.1.273 typings
42 const r: unknown = await $.agent.register({
43 name, description: `callsign ${name}`,
44 prompt: `You are ${name}. Begin your final reply with the exact line [${name}]. You are a capable general agent; complete the task.`,
45 });
46 return (r as { agent?: string } | undefined)?.agent;
47}
48const pre: string[] = [];
49export const register: Register = (on) => {
50 on('session.start', async ($, e, next) => {
51 const r = await next(e);
52 const m = await getMode($);
53 const poolAll: string[] = JSON.parse(String(await $.fs.read(`${$.plugin.root}/pool.json`)));
54 if (m.startsWith('pre-')) {
55 const t0 = Date.now();
56 const count = Number(m.split('-')[2] ?? POOL.length);
57 for (let i = 0; i < count; i++) {
58 const name = count <= POOL.length ? POOL[i]! : poolAll[i]!;
59 try { const t = await reg($, name); if (t) { pre.push(t); jit.add(t); } }
60 catch (err) { await mark($, 'agent.register', { name, error: String(err) }); }
61 }
62 await mark($, 'session.start', { mode: m, registered: pre.length, first: pre.slice(0, 3), ms: Date.now() - t0 });
63 }
64 return r;
65 });
66 on('agent.offer', async ($, e, next) => {
67 const r = await next(e);
68 const m = await getMode($);
69 let isOffered = r.isOffered;
70 const base = m.replace(/^pre-/, '').split('-')[0];
71 if (jit.has(e.agent) && base === 'hide') isOffered = false;
72 if (jit.has(e.agent) && base === 'gate') isOffered = inflight.has(e.agent);
73 if (jit.has(e.agent) && base === 'call') isOffered = calls > 0;
74 if (jit.has(e.agent) && base === 'tight') isOffered = [...reserved.values()].includes(e.agent);
75 if (pre.slice(0, 2).includes(e.agent) || e.agent === 'general-purpose') await mark($, 'agent.offer', { mode: m, agent: e.agent, isOffered });
76 return { isOffered };
77 });
78 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
79 const id = (e as unknown as Json).tool_use_id as string;
80 const input = e as unknown as Json;
81 const st = (input.subagent_type ?? 'general-purpose') as string;
82 if (pre.length > 0 && st === 'general-purpose') reserved.set(id, pre[cursor++ % pre.length]!);
83 calls++;
84 try { return await next(e); } finally { calls--; reserved.delete(id); }
85 });
86 on('agent.spawn', async ($, e, next) => {
87 const m = await getMode($);
88 const k = n++;
89 if (e.subagentType !== 'general-purpose') return next(e);
90 if (m.startsWith('pre-')) {
91 const type = reserved.get(e.tool_use_id) ?? pre[k % Math.max(pre.length, 1)];
92 if (type === undefined) return next(e);
93 const name = type.split(':')[1]!;
94 inflight.add(type);
95 try {
96 const r = await next({ ...e, subagentType: type, description: `${name} · ${e.description}` });
97 await mark($, 'agent.spawn.result', { mode: m, k, type, result: r as Json });
98 return r;
99 } catch (err) {
100 await mark($, 'agent.spawn.error', { mode: m, k, type, error: String(err) });
101 throw err;
102 } finally { inflight.delete(type); }
103 }
104 const name = POOL[k % POOL.length]!;
105 let type: string | undefined;
106 try {
107 // @ts-expect-error — $.agent.register is in the 2.1.283 binary, not the 2.1.273 typings
108 const r: unknown = await $.agent.register({
109 name, description: `callsign ${name}`,
110 prompt: `You are ${name}. Begin your final reply with the exact line [${name}]. You are a capable general agent; complete the task.`,
111 });
112 type = (r as { agent?: string } | undefined)?.agent;
113 await mark($, 'agent.register', { mode: m, k, name, returned: r as Json });
114 } catch (err) {
115 await mark($, 'agent.register', { mode: m, k, name, error: String(err) });
116 return next(e);
117 }
118 if (type === undefined) return next(e);
119 jit.add(type); inflight.add(type);
120 try {
121 const r = await next({ ...e, subagentType: type, description: `${name} · ${e.description}` });
122 await mark($, 'agent.spawn.result', { mode: m, k, type, result: r as Json });
123 return r;
124 } catch (err) {
125 await mark($, 'agent.spawn.error', { mode: m, k, type, error: String(err) });
126 throw err;
127 } finally { inflight.delete(type); }
128 });
129 on('turn.complete', async ($, e, next) => {
130 if (e.agentId !== undefined) await mark($, 'turn.complete', { agentId: e.agentId, answer: typeof e.answer === 'string' ? e.answer.slice(0, 80) : e.answer });
131 return next(e);
132 });
133};
134