SLOPSHOPPER

agent-hub

Agent hub: a pane listing this session's subagents with status, model, turns and tokens, and a box to steer a running one.

newpaneguardcommand
v0.1.0NOASSERTIONupdated 2026-10-03kzarzycki/claude-mods/plugins/agent-hub
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-hub
│ ┃ Agent hub ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents yet. They appear here as they │ ┃ start. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /hub │ ⎿ agent-hub: Agent hub opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent hub
No subagents yet. They appear here as they start.
README

claude-mods

Claude Code mods: plugins whose hooks run inside Claude Code's engine through the in-process $ API. Each one installs on its own from this repository's plugin marketplace. Mods that make decisions with a model (auto-effort, resume-on-stop, ttsr-rules) depend on decision-model, which provides that model.

oh-my-pi features

The first set rebuilds features of oh-my-pi (omp), a coding-agent harness, as mods. The research behind it rates every omp feature by how naturally it fits Claude Code: proposal (written before the build, where the project was called omc). The rest of this README covers that set: how to install it, how it works, and what building it found out about the platform.

ModWhat it does
decision-modelThe decision model, for other mods: $.decision.ask({ state, questions }), with typed questions (choice, noul = probability of yes, score) in the request and answer shape of TypeSafe's System One API. Any endpoint that speaks that protocol is a backend (presets openrouter and typesafe, or a URL; Jev is the default model). Without a key, a small Claude model answers the same questions as text. It does no deciding of its own. /decision shows the backend and every recent decision with the mod that asked; /decision ask <question> -- <state> tries one.
auto-effortAuto effort: asks the decision model how open-ended each prompt is and runs that turn at the chosen effort, up to a ceiling. /effort-rate <prompt>.
resume-on-stopUnexpected stops: a turn that ends on "Let me run the tests next." without acting is spotted by the decision model and gets one resume turn.
ttsr-rulesomp's TTSR rule files (condition, scope, globs, question) from ~/.omp/agent/rules, .omp/rules, ~/.claude/ttsr, .claude/ttsr. A condition rule denies the tool call that would break it and hands the model the rule as the reason. A question rule is put to the decision model after each turn; a yes leaves the rule for the model's next turn. /ttsr, /ttsr reload.
compact-methodsTwo of omp's compaction methods. /compact shake moves old tool output to files under ~/.claude/compact-methods/<session>/ and leaves a pointer the model can Read (no model call). /compact handoff [focus] replaces the context with a handoff document written by a fork of the main thread. autoMethod picks what runs when the context fills.
agent-hub/hub pane: this session's subagents with status, model, turns, tokens and their latest answer, plus a box that sends a message to a running one. /hub list, /hub send <id> <message>.
eval-kernelOne eval tool backed by a long-lived Bun kernel. State persists between cells (top-level declarations, imports). Cells call Claude Code tools (await tool.Read({ file_path })), models (completion()), subagents (agent(), with a JSON Schema schema for a parsed answer) and the decision model (judge(), when decision-model is loaded), in parallel with Promise.all. A subagent's completion notice goes to the cell, not the conversation. Interrupting a cell resets the kernel. Every call goes back through Claude Code, so permissions and other mods' hooks still apply. /eval <code>, /eval vars, /eval reset.

Install

Built and tested on Claude Code 2.1.288. The mods use the in-process $ hook API, so older versions won't load them.

From GitHub (to use them)

claude plugin marketplace add kzarzycki/claude-mods
claude plugin install auto-effort@claude-mods     # pulls in decision-model
claude plugin install resume-on-stop@claude-mods
claude plugin install ttsr-rules@claude-mods
claude plugin install compact-methods@claude-mods
claude plugin install agent-hub@claude-mods
claude plugin install eval-kernel@claude-mods

Pick any subset; each mod works alone, and the three that make decisions pull in decision-model. Inside a session, /plugin does the same from a menu. claude plugin marketplace update claude-mods fetches new versions.

From a clone (to change them)

git clone https://github.com/kzarzycki/claude-mods && cd claude-mods
claude --plugin-dir plugins/decision-model --plugin-dir plugins/auto-effort   # one session

To load them in every session, including ones another tool starts (omnigent, an IDE), list the folders in ~/.claude/settings.json; an interactive session reloads a mod when you save its files:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-mods/plugins/decision-model:/path/to/claude-mods/plugins/auto-effort" } }

Options of mods loaded this way are under <name>@inline (in /config, or claude plugin configure decision-model@inline).

Setup

  • Jev for decisions. Without a key, decision-model asks Claude Haiku. To use Jev, give it an OpenRouter key without echoing it: ``sh read -rs K && printf '{"apiKey":"%s"}' "$K" | claude plugin configure decision-model@claude-mods --values-stdin; unset K ` (decision-model@inline for a clone), or set OPENROUTER_API_KEY. A TypeSafe key needs "endpoint":"typesafe" too; endpoint also takes the URL of any other System One server, with model naming its model. /decision` shows which backend answers.
  • eval-kernel needs Bun on the PATH (or its bun option set to one).
  • TTSR rules are Markdown files in .claude/ttsr/ (project) or ~/.claude/ttsr/; existing omp rule folders are read too. See e2e/ttsr.sh for one of each kind.

Checks

  • scripts/check.sh: validate, unit-test (claude plugin test) and type-check every mod, plus the kernel's self-check. No model calls, under ten seconds.
  • e2e/run.sh [decision eval ttsr compact hub]: real claude sessions with the mods loaded, asserting on output and on files the mods write. They make real model calls; the whole run takes about four minutes. hub and the question-rule check drive an interactive session through expect, because headless claude -p differs there (see below).

How the pieces work

  • A decision model, and the places that use it. decision-model owns the protocol and the backend; each place a decision is made (effort, stops, question rules) is its own mod that asks through $.decision.ask and names its purpose for the log. Swapping Jev for another model, or for Claude, changes no consumer.
  • $.decision across mods. A mod adds a noun to $ in engine.create. The noun's methods are only placeholders: calling $.decision.ask(x) raises the event decision.ask, which decision-model's hook answers with a $ of its own. A $ captured at engine.create can't be used later; the validator refuses it.
  • Eval kernel transport. A mod can write to a child's stdin only once, so the kernel serves HTTP on a Unix socket instead ($.http.fetch with socketPath). A cell that needs the host parks the call and returns it as a call event; the mod runs it and answers with /resume.
  • Subagents from a cell. agent() spawns the subagent and the eval hook waits on /next. The subagent's hand-back (SubagentHandback, or its final turn.complete) answers the call through /answer. Every wait happens inside $.http.fetch, which doesn't count against the hook's 10-second budget. Waiting on an ordinary promise would count.
  • Kernel lifetime. The kernel is started from session.start and restarted when it exits (/eval reset simply exits it). It dies with the mod's module, and a parent-pid watchdog ends it if Claude Code dies.

What the spike found about the platform

Each item was observed in a run, not read from docs.

  • Settings rows are interactive-only. Plugin userConfig rows are in $.config.list() in an interactive session but not under claude -p, where only the engine's 40 rows come back.
  • Notes appended after the last turn are lost headless. A $.session.append made after the last turn of a claude -p run never reaches the transcript, because the process exits. Interactively the model reads it on its next turn.
  • Compaction can't start from a command. A command.run hook may not call $.session.compact; the engine refuses because the command holds the turn. Hence /compact shake, not /shake.
  • Fork can fail after a resume. $.model.fork has nothing to fork right after a session is resumed headless. Handoff falls back to $.model.complete over the messages session.compact passes in.
  • A missing dependency blocks the load. A mod whose dependencies aren't loaded doesn't load at all. A marketplace install pulls the dependency in (+ 1 dependency).
  • Jev returns real distributions; the Claude backend can't. Jev's choice answers carry probabilities (xhigh 0.99, high 0.01, confidence 0.98); the Claude text judge answers one-hot. Jev's noul can sit near the middle where a reader would say yes: "Is DROP TABLE users; in production irreversible?" came back 0.51, so thresholds need tuning per question.
  • Child sessions don't save transcripts. A session started from inside another Claude Code session inherits CLAUDE_CODE_CHILD_SESSION and stops saving transcripts; the e2e scripts unset it.
  • Haiku subagents can miss the task. A Haiku subagent sometimes answers its injected system context ("System initialization acknowledged…") instead of the task. The e2e checks use Sonnet for subagents.
  • Test-kit gaps (claude plugin test):
  • A test hook on session.append never sees $.session.append.
  • A test hook answering agent.spawn gets no agent id.
  • Test plugins run without their closures.

Those paths are covered by e2e instead.

Not done or not verified

  • Other System One servers. Jev through the OpenRouter preset is checked live (the decision and ttsr e2e suites pass against it, answering as typesafe/jev-1.13-20260917). The TypeSafe preset and custom URLs are checked only against faked replies.
  • Steering a running subagent (/hub send, the pane's input). No automated check: the kit can't intercept $.session.append, and an e2e needs a subagent that stays running long enough.
  • TTSR rules scoped to edit/write miss Bash. A model that's refused a Write can write the same file with printf … > file (seen in an e2e run). Scope a rule to tool:bash too if that matters; matching shell redirections to file globs isn't done.
  • Eval cell timeouts. A cell has no timeout. A synchronous infinite loop blocks the kernel until /eval reset (or reset: true). agent()'s schema is parsed, not validated: a wrong shape reaches the cell as is.
  • omp features left out of this MVP. Snapcompact, omp's soft compaction, idle compaction, TTSR rules on streamed text (Claude Code can't take back text it has already shown), and multi-vendor models.
Source 2 files
hooks/register.tsx 124 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3import type { HubAgent } from "../types";
4
5// agent-hub: one place to watch and steer this session's subagents.
6// - The list is $.agent.list, plus what hooks see: the model and turns from turn.step, tokens and
7//   the latest answer from turn.complete (AgentInfo carries neither).
8// - Steering is $.session.append to the subagent's loop; it reads the note at its next step.
9
10const PANE = "agent-hub";
11const agents = atom({ plugin: "agent-hub", key: "agents" } as const, []);
12const selected = atom({ plugin: "agent-hub", key: "selected" } as const, "");
13const sent = atom({ plugin: "agent-hub", key: "sent" } as const, "");
14
15const blank = (id: string): HubAgent => ({ id, description: "", type: "", status: "running", turns: 0, tokens: 0 });
16
17/** Merge $.agent.list into what the hooks have seen. */
18async function refresh($: EngineInterface) {
19  const listed = await $.agent.list();
20  await update($, agents, seen =>
21    listed.map(a => ({ ...(seen.find(s => s.id === a.id) ?? blank(a.id)), description: a.description, type: a.type, status: a.status })),
22  );
23}
24
25async function patch($: EngineInterface, id: string, change: (a: HubAgent) => HubAgent) {
26  await update($, agents, list => (list.some(a => a.id === id) ? list : [...list, blank(id)]).map(a => (a.id === id ? change(a) : a)));
27}
28
29async function steer($: EngineInterface, id: string, text: string): Promise<string> {
30  const r = await $.session.append({ agentId: id, message: { type: "user", content: [{ type: "text", text: `Message from the person running this session: ${text}` }] } });
31  return r.deny !== undefined ? `not sent: ${r.deny}` : `sent to ${id}`;
32}
33
34const table = (list: HubAgent[]) =>
35  list.length === 0
36    ? "no subagents in this session"
37    : list.map(a => `${a.id}  ${a.status.padEnd(9)} ${(a.model ?? "?").padEnd(16)} ${String(a.turns).padStart(3)} turns ${String(a.tokens).padStart(8)} tok  ${a.type}: ${a.description}`).join("\n");
38
39export const register: Register = on => {
40  on("session.start", async ($, e, next) => {
41    await $.command.register({ name: "hub", description: "agent-hub: subagents pane; `/hub list`, `/hub send <agent id> <message>`", argumentHint: "[list | send <id> <message>]" });
42    return next(e);
43  });
44
45  on("turn.step", async function* ($, e, next) {
46    if (e.agentId) await patch($, e.agentId, a => ({ ...a, model: e.model, status: "running" }));
47    return yield* next(e);
48  });
49
50  on("turn.complete", async ($, e, next) => {
51    if (e.agentId) {
52      const u = e.usage;
53      const used = u ? u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0;
54      await patch($, e.agentId, a => ({ ...a, turns: a.turns + 1, tokens: a.tokens + used, last: e.answer.slice(-200) || a.last }));
55      await refresh($);
56    }
57    return next(e);
58  });
59
60  // A finished Agent tool call is a new agent (or a done one): refresh the list.
61  on("tool.call", { tool: "Agent" }, async ($, e, next) => {
62    const r = await next(e);
63    await refresh($);
64    return r;
65  });
66
67  on("command.run", { command: "hub" }, async ($, e) => {
68    await refresh($);
69    const send = /^send\s+(\S+)\s+([\s\S]+)$/.exec(e.args.trim());
70    if (send) {
71      const id = (await read($, agents)).find(a => a.id.startsWith(send[1]!))?.id ?? send[1]!;
72      return { text: await steer($, id, send[2]!) };
73    }
74    if (e.args.trim() === "list") return { text: table(await read($, agents)) };
75    const placed = await $.ui.open({ id: PANE, title: "Agent hub", focus: true, closeOnEscape: true });
76    // Headless there is nowhere to put a pane: print the table instead.
77    return { text: placed.isPlaced ? "Agent hub opened." : table(await read($, agents)) };
78  });
79
80  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
81    const list = await read($, agents);
82    if (e.surface !== "terminal") {
83      // ponytail: steering controls are drawn on the terminal only; other surfaces get the table.
84      const { Text } = $.ui.resolve(e);
85      return <Text>{table(list)}</Text>;
86    }
87    const { Box, Text, Select, Input } = $.ui.resolve(e);
88    const pick = (await read($, selected)) || list[0]?.id || "";
89    const note = await read($, sent);
90    const width = e.props.bodyColumns;
91    return (
92      <Box flexDirection="column">
93        {list.length === 0 && <Text dimColor>No subagents yet. They appear here as they start.</Text>}
94        {list.map(a => (
95          <Box flexDirection="column">
96            <Text bold={a.id === pick} color={a.status === "running" ? "green" : undefined} wrap="truncate">
97              {a.id === pick ? "▸ " : "  "}
98              {a.status.padEnd(9)} {(a.model ?? "").padEnd(14)} {a.turns}t {a.tokens}tok {a.type}: {a.description}
99            </Text>
100            {a.last && <Text dimColor wrap="truncate">{"    " + a.last.replace(/\s+/g, " ").slice(0, Math.max(10, width - 6))}</Text>}
101          </Box>
102        ))}
103        {list.length > 0 && (
104          <Select key="pick" label="Agent" value={pick} options={list.map(a => ({ value: a.id, label: `${a.id.slice(0, 8)} ${a.description}` }))} onSelect={(v: string) => void update($, selected, () => v)} />
105        )}
106        {pick && (
107          <Input
108            key="steer"
109            label="Steer"
110            placeholder={`message to ${pick.slice(0, 8)}`}
111            submitLabel="Send"
112            onSubmit={async (text: string) => {
113              if (!text.trim()) return;
114              const result = await steer($, pick, text);
115              await update($, sent, () => `${text.slice(0, 50)} (${result})`);
116            }}
117          />
118        )}
119        {note && <Text dimColor>last sent: {note}</Text>}
120      </Box>
121    );
122  });
123};
124
types/index.d.ts 18 lines
1export type HubAgent = {
2  id: string;
3  description: string;
4  type: string;
5  status: string;
6  model?: string;
7  turns: number;
8  tokens: number;
9  /** The tail of its latest answer. */
10  last?: string;
11};
12
13declare module "claude-code" {
14  interface PluginState {
15    "agent-hub": { agents: HubAgent[]; selected: string; sent: string };
16  }
17}
18