SLOPSHOPPER

mods-naming-probe

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

newguardagents
A shopper browsing a rack in a slop shop
README

mods-naming-probe (throwaway)

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.

What the mod does (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.
  • Every fire appends one JSONL line to markers.jsonl via $.fs (hooks have no Node).

Headless result (run 1, markers.run1-headless.jsonl, debug.run1-headless.log)

  • Q1 agent.spawn fires for background: true spawns — yes (3/3).
  • Q2 rewritten description is what $.agent.list() reports as the row label (AgentInfo.description), and what the Agent tool result carries — yes at the data layer.
  • Q3 $.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.

Interactive check (visual half of Q2/Q3) — run this yourself

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 20 via 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):

  1. Q2: do rows read Probe-1 · …, Probe-2 · …, Probe-3 · …?
  2. Q3: do rows 2 and 3 show type mods-naming-probe:ProbeHudson (or ProbeHudson) rather than general-purpose?
  3. Screenshot it. Then check 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).

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