Throwaway probe: can function hooks (agent.spawn / $.agent.register / turn.complete) give parallel subagents distinct visible names? Writes JSONL markers on…

Claude Mods (function hooks) probe for named-subagents: can a mod give parallel subagents distinct visible names without persona files + a PreToolUse rewrite? Tested on Claude Code 2.1.283. Do not ship.
hooks/probe.ts)session.start → $.agent.register({ name: "ProbeHudson", ... }) (untyped in the 2.1.273 d.ts; registers as mods-naming-probe:ProbeHudson — plugin-namespaced).agent.spawn → rewrites description to Probe-<n> · <desc>; on spawn #2, if it is general-purpose, rewrites subagentType to the registered type.tool.call{Agent}, agent.offer, turn.start, turn.complete → logged; $.agent.list() snapshotted after each spawn/subagent turn.markers.jsonl via $.fs (hooks have no Node).markers.run1-headless.jsonl, debug.run1-headless.log)agent.spawn fires for background: true spawns — yes (3/3).$.agent.list() reports as the row label (AgentInfo.description), and what the Agent tool result carries — yes at the data layer.$.agent.register works; the model is offered the type; a spawn-time subagentType rewrite to it runs the registered prompt (answer [ProbeHudson]) — yes.turn.complete for a subagent carries agentId (= spawn result agentId) — the name-release hook.Run from a scratch dir with user settings skipped, so the existing named-subagents PreToolUse roster hook does not rewrite subagent_type underneath the probe:
mkdir -p /tmp/mods-probe-run && cd /tmp/mods-probe-run && \
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --model haiku --setting-sources project,local \
--plugin-dir ~/Dev/named-subagents/probes/mods-naming-probe \
--debug-file ~/Dev/named-subagents/probes/mods-naming-probe/debug.interactive.log
Then paste:
Dispatch two background general-purpose agents in one message, each told to run
sleep 20via Bash and then reply 'ok'. Then dispatch one agent with subagent_type mods-naming-probe:ProbeHudson doing the same.
While they run, look at the task tree / background-tasks list (and /agents or the tasks panel):
Probe-1 · …, Probe-2 · …, Probe-3 · …?mods-naming-probe:ProbeHudson (or ProbeHudson) rather than general-purpose?markers.jsonl (new lines appended) for the matching agent.list snapshots.Optional coexistence run: repeat without --setting-sources project,local to see how the roster PreToolUse hook and the agent.spawn rewrite interact (which one wins subagentType).
hooks/probe.ts 131 lines1// mods-naming-probe — throwaway. Answers, for Claude Code 2.1.283 function hooks:
2// Q1 does `agent.spawn` fire for background-dispatched agents?
3// Q2 does rewriting `description` in agent.spawn become the agent's row label?
4// Q3 can `$.agent.register` add an agent type at runtime (ProbeHudson), and can
5// an agent.spawn rewrite of `subagentType` dispatch to it?
6// + what `turn.complete` carries for a subagent (agentId?) — needed for name release.
7//
8// Witnessing rule: every hook fire appends a JSONL line to <plugin root>/markers.jsonl
9// via $.fs (hooks run with no Node, so no node:fs). Never judge from $.ui.log.
10
11import type { Register, EngineInterface } from 'claude-code';
12
13type Json = Record<string, unknown>;
14
15const PROBE_AGENT = 'ProbeHudson';
16let registeredAs: string | undefined; // what $.agent.register returned ({ agent })
17let registerTried = false;
18let spawnCount = 0;
19let writeChain: Promise<void> = Promise.resolve();
20
21const clip = (s: unknown, n = 160): unknown =>
22 typeof s === 'string' && s.length > n ? `${s.slice(0, n)}…(${s.length})` : s;
23
24// Serialized read-append-write: $.fs.write replaces the file, so append = read + write.
25function mark($: EngineInterface, hook: string, data: Json): Promise<void> {
26 const path = `${$.plugin.root}/markers.jsonl`;
27 const line = JSON.stringify({ ts: new Date().toISOString(), hook, ...data }) + '\n';
28 writeChain = writeChain.then(async () => {
29 const prev = (await $.fs.exists(path)) ? await $.fs.read(path) : '';
30 await $.fs.write(path, prev + line);
31 }).catch(() => undefined); // audit-allow: fail-loud — probe telemetry; a lost line shows as a gap
32 return writeChain;
33}
34
35async function snapshotAgents($: EngineInterface, why: string): Promise<void> {
36 try {
37 const list = await $.agent.list();
38 await mark($, 'agent.list', { why, agents: list });
39 } catch (err) {
40 await mark($, 'agent.list', { why, error: String(err) });
41 }
42}
43
44async function tryRegister($: EngineInterface, from: string): Promise<void> {
45 if (registerTried) return;
46 registerTried = true;
47 try {
48 // @ts-expect-error — $.agent.register exists in the 2.1.283 binary, not in the 2.1.273 typings.
49 const r: unknown = await $.agent.register({
50 name: PROBE_AGENT,
51 description: 'Probe persona registered at runtime by the mods-naming-probe plugin. Use for trivial probe tasks.',
52 prompt: `You are ${PROBE_AGENT}, a probe agent. Start your reply with the line [${PROBE_AGENT}] and then answer in one word.`,
53 model: 'haiku',
54 tools: ['Read'],
55 });
56 registeredAs = (r as { agent?: string } | undefined)?.agent;
57 await mark($, 'agent.register', { from, ok: true, returned: r as Json });
58 } catch (err) {
59 await mark($, 'agent.register', { from, ok: false, error: String(err) });
60 }
61}
62
63export const register: Register = (on) => {
64 on('session.start', async ($, e, next) => {
65 const r = await next(e);
66 await mark($, 'session.start', { cwd: e.cwd, isInteractive: e.isInteractive, surface: e.surface });
67 await tryRegister($, 'session.start');
68 return r;
69 });
70
71 on('turn.start', async ($, e, next) => {
72 await mark($, 'turn.start', { turnId: e.turnId, text: clip(e.text, 80) });
73 await tryRegister($, 'turn.start'); // fallback if session.start did not fire / had no session
74 return next(e);
75 });
76
77 on('agent.offer', async ($, e, next) => {
78 const r = await next(e);
79 if (/probe|general-purpose/i.test(e.agent)) {
80 await mark($, 'agent.offer', { agent: e.agent, source: e.source, provider: e.provider, isOffered: r.isOffered });
81 }
82 return r;
83 });
84
85 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
86 const input = e as unknown as Json;
87 await mark($, 'tool.call.Agent', {
88 tool_use_id: input.tool_use_id, agentId: input.agentId,
89 subagent_type: input.subagent_type, description: input.description,
90 run_in_background: input.run_in_background, name: input.name,
91 });
92 const r = await next(e);
93 await mark($, 'tool.call.Agent.result', { tool_use_id: input.tool_use_id, result: clip(JSON.stringify(r), 600) });
94 return r;
95 });
96
97 on('agent.spawn', async ($, e, next) => {
98 const n = ++spawnCount;
99 await mark($, 'agent.spawn', {
100 n, tool_use_id: e.tool_use_id, description: e.description, subagentType: e.subagentType,
101 background: e.background, fork: e.fork, name: e.name, parentAgentId: e.parentAgentId,
102 model: e.model, provider: e.provider, prompt: clip(e.prompt, 80),
103 });
104 const rewritten = { ...e, description: `Probe-${n} · ${e.description}` };
105 // Q3: route the 2nd general-purpose spawn to the runtime-registered type.
106 if (n === 2 && registeredAs !== undefined && e.subagentType === 'general-purpose') {
107 rewritten.subagentType = registeredAs;
108 }
109 try {
110 const r = await next(rewritten);
111 await mark($, 'agent.spawn.result', {
112 n, sentDescription: rewritten.description, sentSubagentType: rewritten.subagentType, result: r as Json,
113 });
114 await snapshotAgents($, `after spawn ${n}`);
115 return r;
116 } catch (err) {
117 await mark($, 'agent.spawn.error', { n, sentSubagentType: rewritten.subagentType, error: String(err) });
118 throw err;
119 }
120 });
121
122 on('turn.complete', async ($, e, next) => {
123 await mark($, 'turn.complete', {
124 agentId: e.agentId, turnId: e.turnId, reason: e.reason, isAborted: e.isAborted,
125 durationMs: e.durationMs, answer: clip(e.answer, 120),
126 });
127 if (e.agentId !== undefined) await snapshotAgents($, `turn.complete ${e.agentId}`);
128 return next(e);
129 });
130};
131