SLOPSHOPPER

agents

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

newagents
v0.1.0no licenseupdated 2026-10-09djavrell/dotfile/claude/mods/agents
A shopper browsing a rack in a slop shop
README

agents

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.

Example

One entry of the list:

{ "id": "a1b2c3", "type": "Explore", "description": "find the config loader", "status": "completed" }
Source 3 files
hooks/register.ts 98 lines
1// 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};
98
hooks/lib/entries.ts 61 lines
1import 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";
61
types/index.d.ts 34 lines
1// 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