SLOPSHOPPER

n

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

newguardagents
A shopper browsing a rack in a slop shop
README

named-subagents

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.

CI python deps

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

Quick start: the Claude Code plugin

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.

Without the plugin

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 varEffect
NAMED_SUBAGENTS_HOOK_DISABLE=1pause the hooks without uninstalling
NAMED_SUBAGENTS_LEDGERledger path (default ~/.local/state/named-subagents/hook-ledger.json)
NAMED_SUBAGENTS_QUEUE_DIRdispatch-queue dir (default ~/.local/state/named-subagents/queue/)
NAMED_SUBAGENTS_HOOK_BIO=1add the figure's one-line bio to the identity block
NAMED_SUBAGENTS_PLUGIN_FORCE=1run the plugin's hooks even next to a settings.json install

Themes

395 names in 14 categories, each globally unique, each with a one-line bio:

CategoryTask shapeThemee.g.
exploremap / search a codebaseExplorers & navigatorsMagellan, Shackleton
codeimplement featuresProgrammers & computing pioneersTuring, Hopper
researchexternal info gatheringScientistsCurie, Feynman
reflectdesign rationalePhilosophersSocrates, Kant
debugroot-cause huntingDetectivesHolmes, Poirot
testedge cases, adversarialTrickstersLoki, Anansi
reviewcritique, verdictJudges & juristsSolomon, Ginsburg
securityaudit, threat modelGuardians & sentinelsArgus, Heimdall
designUI / UX / visualArtists & designersDaVinci, Rams
dataanalysis, stats, MLMathematiciansGauss, Noether
orchestrateplan, coordinateStrategistsSunTzu, Napoleon
docstechnical writingWritersOrwell, Borges
buildinfra / refactor / perfEngineers & inventorsTesla, Brunel
defaultcatch-allStarsOrion, 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.

Library

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

CLI

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
  • Pins bypass the ledger and are reserved out of normal draws.
  • --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.

Development

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.

License

MIT

Source 1 files
hooks/jit.ts 134 lines
1// 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