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

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.
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.
| Mod | What it does |
|---|---|
decision-model | The 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-effort | Auto 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-stop | Unexpected 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-rules | omp'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-methods | Two 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-kernel | One 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. |
Built and tested on Claude Code 2.1.288. The mods use the in-process $ hook API, so older versions won't load 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.
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).
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)..claude/ttsr/ (project) or ~/.claude/ttsr/; existing omp rule folders are read too. See e2e/ttsr.sh for one of each kind.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).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.$.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.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.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.Each item was observed in a run, not read from docs.
userConfig rows are in $.config.list() in an interactive session but not under claude -p, where only the engine's 40 rows come back.$.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.command.run hook may not call $.session.compact; the engine refuses because the command holds the turn. Hence /compact shake, not /shake.$.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.dependencies aren't loaded doesn't load at all. A marketplace install pulls the dependency in (+ 1 dependency).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.CLAUDE_CODE_CHILD_SESSION and stops saving transcripts; the e2e scripts unset it.claude plugin test):session.append never sees $.session.append.agent.spawn gets no agent id.Those paths are covered by e2e instead.
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./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.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 reset (or reset: true). agent()'s schema is parsed, not validated: a wrong shape reaches the cell as is.hooks/register.tsx 124 lines1import { 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};
124types/index.d.ts 18 lines1export 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