Single source of the session's agents: collects them and publishes them as state other mods read

Single source of the session's subagents. The mod collects every agent the session spawns and keeps the list as state (agents.list, oldest first), so other mods can show or react to agents without tracking them on their own.
It draws nothing itself: panes, bands and toasts read that state. Another mod gets it by listing agents under its dependencies. The session pane is the current consumer.
One entry of the list:
{ "id": "a1b2c3", "type": "Explore", "description": "find the config loader", "status": "completed" }hooks/register.ts 98 lines1// Single source of the session's agents. This mod draws nothing: it collects the agents and publishes
2// them as its `list` state; the session pane (and any later band, toast or command) reads that state.
3// $ and the atom stay in this file (the validator does not follow them across an import); the pure
4// list operations live in lib/entries.ts.
5import { atom, update } from "claude-code";
6import type { EngineInterface, Register } from "claude-code";
7
8import { mergeListed, newEntry, patchEntry, statusOfEnd, upsertEntry } from "./lib/entries";
9
10const list = atom({ plugin: "agents", key: "list" } as const, []);
11
12// bumped by every list refresh: a run that is no longer the latest drops its result
13let listGeneration = 0;
14
15async function refreshFromList($: EngineInterface) {
16 const thisGeneration = ++listGeneration;
17 const agentInfos = await $.agent.list();
18 if (thisGeneration !== listGeneration) return;
19 const listed = agentInfos.map((agentInfo) => ({
20 id: agentInfo.id,
21 parentId: agentInfo.parentId,
22 type: agentInfo.type,
23 description: agentInfo.description,
24 status: agentInfo.status,
25 }));
26 await update($, list, (entries) => mergeListed(entries, listed));
27}
28
29export const register: Register = (on) => {
30 on("session.start", async ($, event, next) => {
31 void refreshFromList($).catch(() => {});
32
33 return next(event);
34 });
35
36 on("agent.spawn", async ($, event, next) => {
37 const result = await next(event);
38 const agentId = "agentId" in result ? result.agentId : undefined;
39 if (agentId) {
40 const startedAt = await $.clock.now();
41 const entry = newEntry({
42 id: agentId,
43 parentId: event.parentAgentId,
44 type: event.subagentType,
45 description: event.description,
46 status: "running",
47 model: "model" in result ? result.model : event.model,
48 background: event.background,
49 startedAt,
50 });
51 void update($, list, (entries) => upsertEntry(entries, entry)).catch(() => {});
52 void refreshFromList($).catch(() => {});
53 }
54
55 return result;
56 });
57
58 // one step = one model request; counted before it goes out and once it came back
59 on("turn.step", async function* ($, event, next) {
60 const agentId = event.agentId;
61 if (agentId)
62 void update($, list, (entries) =>
63 patchEntry(entries, agentId, (entry) => ({
64 stepsStarted: entry.stepsStarted + 1,
65 model: event.model,
66 })),
67 ).catch(() => {});
68
69 const result = yield* next(event);
70
71 if (agentId)
72 void update($, list, (entries) =>
73 patchEntry(entries, agentId, (entry) => ({ stepsDone: entry.stepsDone + 1 })),
74 ).catch(() => {});
75
76 return result;
77 });
78
79 // a subagent's run is one turn: its end is the agent's end
80 on("turn.complete", async ($, event, next) => {
81 const result = await next(event);
82 const agentId = event.agentId;
83 if (agentId) {
84 const endedAt = await $.clock.now();
85 void update($, list, (entries) =>
86 patchEntry(entries, agentId, () => ({
87 endedAt,
88 endReason: event.reason,
89 status: statusOfEnd(event.reason),
90 })),
91 ).catch(() => {});
92 void refreshFromList($).catch(() => {});
93 }
94
95 return result;
96 });
97};
98hooks/lib/entries.ts 61 lines1import type { AgentEntry } from "../../types";
2
3// ponytail: keeps the last 30 agents; older ones drop off the list
4export const MAX_ENTRIES = 30;
5
6// what $.agent.list() says about one agent
7export type ListedAgent = {
8 id: string;
9 parentId?: string;
10 type: string;
11 description: string;
12 status: string;
13};
14
15export const newEntry = (
16 fields: Omit<AgentEntry, "stepsStarted" | "stepsDone">,
17): AgentEntry => ({ ...fields, stepsStarted: 0, stepsDone: 0 });
18
19// adds an agent, or replaces the fields given on the one already there
20export const upsertEntry = (
21 entries: readonly AgentEntry[],
22 entry: AgentEntry,
23): AgentEntry[] => {
24 const existing = entries.find((candidate) => candidate.id === entry.id);
25 if (!existing) return [...entries, entry].slice(-MAX_ENTRIES);
26 return entries.map((candidate) =>
27 candidate.id === entry.id ? { ...candidate, ...entry, stepsStarted: candidate.stepsStarted, stepsDone: candidate.stepsDone } : candidate,
28 );
29};
30
31// changes an agent already known; ids never seen (the engine's own forks: compaction, memory) are ignored
32export const patchEntry = (
33 entries: readonly AgentEntry[],
34 agentId: string,
35 patch: (entry: AgentEntry) => Partial<AgentEntry>,
36): AgentEntry[] =>
37 entries.map((entry) => (entry.id === agentId ? { ...entry, ...patch(entry) } : entry));
38
39// the engine's list brings statuses, and agents born without a spawn (a forked skill);
40// agents it has dropped keep their last known state
41export const mergeListed = (
42 entries: readonly AgentEntry[],
43 listed: readonly ListedAgent[],
44): AgentEntry[] => {
45 let merged = [...entries];
46 for (const agent of listed) {
47 const existing = merged.find((entry) => entry.id === agent.id);
48 merged = existing
49 ? patchEntry(merged, agent.id, () => ({
50 status: agent.status,
51 parentId: agent.parentId ?? existing.parentId,
52 }))
53 : upsertEntry(merged, newEntry(agent));
54 }
55 return merged;
56};
57
58// a finished turn's reason, as a status, until the engine's list says otherwise
59export const statusOfEnd = (reason: string) =>
60 reason === "answer" ? "completed" : reason === "aborted" ? "killed" : "failed";
61types/index.d.ts 34 lines1// One agent of the session as this mod publishes it. Consumers list "agents" under `dependencies`
2// in their plugin.json, import this type from "agents", and read the list with
3// read($, atom({ plugin: "agents", key: "list" } as const, []))
4export type AgentEntry = {
5 id: string
6 // the agent that spawned it; absent when the main conversation did
7 parentId?: string
8 // the agent type it was spawned as (general-purpose, Explore, ...)
9 type: string
10 description: string
11 // pending, running, waiting, idle, completed, failed, killed
12 status: string
13 // the model it runs on, as the engine resolved it (claude-haiku-4-5-..., or an alias)
14 model?: string
15 background?: boolean
16 // $.clock.now() milliseconds
17 startedAt?: number
18 endedAt?: number
19 // why its last turn ended: answer, aborted, refusal, error
20 endReason?: string
21 // model requests (turn.step) it started, and the ones that came back
22 stepsStarted: number
23 stepsDone: number
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 agents: {
29 // spawn order, oldest first; finished agents are kept (the engine's own list drops them)
30 list: AgentEntry[]
31 }
32 }
33}
34