SLOPSHOPPER

eval-kernel

A persistent JavaScript kernel the model drives with one `eval` tool: state survives between cells, and cells call Claude Code tools, models and subagents.

newguardcommandprompttoolmodel
v0.1.0NOASSERTIONupdated 2026-10-03kzarzycki/claude-mods/plugins/eval-kernel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · eval-kernel
› fix the failing auth test and add an audit log call ⏺ 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 › /eval ⎿ eval-kernel: kernel not running at /tmp/eval-kernel-preview--mxkzg1.sock (runtime: bun) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
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 1 files
hooks/register.ts 265 lines
1import type { EngineInterface, Register } from "claude-code";
2
3// eval-kernel: one `eval` tool backed by a long-lived Bun kernel (../kernel/kernel.ts) that keeps
4// JavaScript state between cells. A cell's host calls (tool.X, completion, agent, tools) come back
5// here as `call` events; this module runs them through $ and answers, so every tool a cell calls
6// passes Claude Code's permission checks and every other plugin's hooks.
7
8const TOOL = "eval";
9const TOOL_ID = "mcp__eval-kernel__eval";
10
11const DESCRIPTION = `Run JavaScript in a persistent Bun kernel, like a notebook cell.
12
13- Top-level \`const\`/\`let\`/\`function\`/\`class\` declarations and \`import\`s at the start of a line persist into later cells. A trailing expression is the cell's value. Top-level await works.
14- Bun, fetch and node:* modules are available. console.log and display(value) are captured.
15- Claude Code tools: \`await tool.Read({ file_path })\` resolves to the tool's text and throws if the tool errors; \`tool.call(name, args, { raw: true })\` returns { text, result, isError } for any tool, MCP tools included. \`await tools()\` lists them. Calls go through the normal permission checks.
16- Models and agents: \`await completion(prompt, { model: "haiku", system, maxTokens })\` returns text; \`await agent(prompt, { description, subagentType, model, schema })\` runs a subagent and returns its answer: the bare text, or with a JSON Schema \`schema\`, the parsed JSON value. Run several at once with Promise.all.
17- Decisions: \`await judge({ type: "noul", instructions: "Is it risky?" }, state)\` asks the decision model (decision-model mod; Jev when configured) one typed question (\`noul\` yes-probability, \`choice\` with \`criteria\` {label: rubric}, \`score\` with ordered \`criteria\`) and returns its answer.
18- Use it to fan work out (read 30 files, run 5 subagents, aggregate), to keep data between steps, or to compute instead of reasoning.
19- Pass \`reset: true\` to restart the kernel with empty state.`;
20
21type Event =
22  | { status: "call"; callId: string; kind: string; payload: any }
23  | { status: "done"; output: string; value?: string }
24  | { status: "error"; output: string; error: string };
25
26/** The kernel's socket; set once the session starts. */
27let sock = "";
28/** Subagents a running cell waits on: agentId → the kernel's callId and the schema its answer must match. */
29const agentCalls = new Map<string, { callId: string; schema?: unknown }>();
30
31/** Every subagent a cell started; their completion notices belong to the cell, not the parent. */
32// ponytail: grows for the session's life (a few ids per agent() call).
33const cellAgents = new Set<string>();
34
35/** The agent ids a delivered notice is about: task-notification tags or a hand-back's sender. */
36function noticeAgents(text: string): string[] {
37  return [...text.matchAll(/<task-id>([\w-]+)<\/task-id>|from="([\w-]+)"/g)].map(m => (m[1] ?? m[2])!);
38}
39
40/** The subagent's hand-back is a report to a program, so ask for the answer alone. */
41function agentPrompt(prompt: string, schema: unknown): string {
42  return schema === undefined
43    ? `${prompt}\n\nYour hand-back report is returned verbatim to a program as your answer: make it only the answer, with no preamble, summary or sign-off.`
44    : `${prompt}\n\nYour hand-back report is parsed by a program: make it only a JSON value matching this JSON Schema, with no prose and no code fences.\n${JSON.stringify(schema)}`;
45}
46
47/** The answer a cell's agent() resolves to; with a schema, the parsed JSON. */
48function agentAnswer(text: string, schema: unknown): { ok: true; value: unknown } | { ok: false; error: string } {
49  if (schema === undefined) return { ok: true, value: text.trim() };
50  // ponytail: parses only, no schema validation. Upgrade path: validate and send the subagent the errors to fix.
51  try {
52    return { ok: true, value: JSON.parse(text.trim().replace(/^```(?:json)?\s*|\s*```$/g, "")) };
53  } catch {
54    return { ok: false, error: `agent: the answer is not JSON: ${text.slice(0, 200)}` };
55  }
56}
57
58async function post($: EngineInterface, path: string, body: unknown = {}): Promise<any> {
59  const res = await $.http.fetch(`http://localhost${path}`, { method: "POST", socketPath: sock, body: JSON.stringify(body), headers: { "Content-Type": "application/json" } });
60  return JSON.parse(res.text);
61}
62
63/** Wait (up to ~5 s) for the kernel to answer /health. */
64async function ready($: EngineInterface): Promise<boolean> {
65  for (let i = 0; i < 50; i++) {
66    try {
67      const res = await $.http.fetch("http://localhost/health", { socketPath: sock });
68      if (res.ok) return true;
69    } catch {}
70    await $.clock.sleep(100);
71  }
72  return false;
73}
74
75/** Keep one kernel alive for the session: a child that exits (reset, crash) is started again. */
76async function supervise($: EngineInterface, argv: string[], cwd: string) {
77  // ponytail: fixed restart cap, no backoff. Upgrade path: back off on quick consecutive exits.
78  for (let starts = 0; starts < 50; starts++) {
79    try {
80      for await (const chunk of $.process.spawn({ argv, cwd })) $.ui.log(`eval-kernel kernel: ${chunk.text.trimEnd()}`, { to: "debug" });
81    } catch (err) {
82      $.ui.log(`eval-kernel kernel failed to start: ${String(err)}`, { to: "debug" });
83      await $.clock.sleep(1000);
84    }
85  }
86}
87
88/** Run one host call a cell made. Throws to make the cell's await reject. */
89async function hostCall($: EngineInterface, kind: string, p: any): Promise<unknown> {
90  if (kind === "tool") {
91    if (p.tool === TOOL_ID) throw new Error("a cell can't call eval itself");
92    const r = await $.tool.call({ tool: String(p.tool), ...(p.args ?? {}) });
93    if (r.deny !== undefined) return { isError: true, text: `denied: ${r.deny}` };
94    return { text: r.text ?? (typeof r.result === "string" ? r.result : JSON.stringify(r.result)), result: r.result, isError: r.isError === true };
95  }
96  if (kind === "tools") return await $.tool.list();
97  if (kind === "complete") {
98    const r = await $.model.complete({ model: String(p.model ?? "haiku"), prompt: String(p.prompt), system: p.system, maxTokens: p.maxTokens, effort: p.effort });
99    if (!r.isAnswered) throw new Error(`completion failed: ${r.reason}`);
100    return r.text;
101  }
102  if (kind === "judge") {
103    // Optional: decision-model adds $.decision when it is loaded; eval-kernel works without it.
104    type Ask = { decision: { ask: (r: object) => Promise<{ answers: Record<string, unknown> }> } };
105    let r: { answers: Record<string, unknown> };
106    try {
107      r = await ($ as unknown as Ask).decision.ask({ purpose: "eval", state: p.state, questions: { q: p.question } });
108    } catch (err) {
109      if (err instanceof TypeError && /decision/.test(err.message)) throw new Error("judge() needs the decision-model mod");
110      throw err;
111    }
112    return r.answers.q;
113  }
114  throw new Error(`unknown host call ${kind}`);
115}
116
117/** Run a cell to its end, answering its host calls. */
118async function runCell($: EngineInterface, code: string, maxCalls: number, signal?: AbortSignal): Promise<Event> {
119  if (!(await ready($))) return { status: "error", output: "", error: "the kernel is not running (is bun installed? see /eval)" };
120  // An interrupt stops the cell: no request can be cancelled, so the kernel is reset, which ends the
121  // wait below and drops the cell's pending subagent answers.
122  // ponytail: interrupting loses kernel state. Upgrade path: an /interrupt endpoint that ends only the running cell.
123  const interrupted = () => {
124    agentCalls.clear();
125    void post($, "/reset").catch(() => {});
126  };
127  signal?.addEventListener("abort", interrupted, { once: true });
128  try {
129    return await drive($, code, maxCalls, signal);
130  } catch (err) {
131    if (signal?.aborted) return { status: "error", output: "", error: "interrupted; the kernel was reset" };
132    throw err;
133  } finally {
134    signal?.removeEventListener("abort", interrupted);
135  }
136}
137
138async function drive($: EngineInterface, code: string, maxCalls: number, signal?: AbortSignal): Promise<Event> {
139  let ev: Event = await post($, "/run", { code, cwd: await $.session.cwd() });
140  let calls = 0;
141  while (ev.status === "call") {
142    if (signal?.aborted) return { status: "error", output: "", error: "interrupted; the kernel was reset" };
143    if (++calls > maxCalls) {
144      await post($, "/reset").catch(() => {});
145      return { status: "error", output: "", error: `the cell made more than ${maxCalls} host calls; the kernel was reset` };
146    }
147    const { callId, kind, payload } = ev;
148    if (kind === "agent") {
149      // Answered later by the subagent's own turn.complete (below); meanwhile take the next event.
150      try {
151        const spawned = await $.agent.spawn({ prompt: agentPrompt(String(payload.prompt), payload.schema), description: String(payload.description ?? "eval cell subagent"), subagentType: payload.subagentType, model: payload.model });
152        if (spawned.deny !== undefined || !spawned.agentId) throw new Error(spawned.deny ?? "no agent id");
153        agentCalls.set(spawned.agentId, { callId, schema: payload.schema });
154        cellAgents.add(spawned.agentId);
155        ev = await post($, "/next");
156      } catch (err) {
157        ev = await post($, "/resume", { callId, ok: false, error: `agent: ${String(err)}` });
158      }
159      continue;
160    }
161    try {
162      ev = await post($, "/resume", { callId, ok: true, value: await hostCall($, kind, payload) });
163    } catch (err) {
164      ev = await post($, "/resume", { callId, ok: false, error: err instanceof Error ? err.message : String(err) });
165    }
166  }
167  return ev;
168}
169
170function render(ev: Event): string {
171  if (ev.status === "error") return [ev.output, `Error: ${ev.error}`].filter(Boolean).join("\n");
172  if (ev.status === "done") return [ev.output, ev.value !== undefined ? `=> ${ev.value}` : ""].filter(Boolean).join("\n") || "(no output)";
173  return "(cell still waiting)";
174}
175
176export const register: Register = (on, options) => {
177  const bun = String(options.bun || "bun");
178  const maxCalls = Number(options.maxCalls ?? 200);
179
180  on("session.start", async ($, e, next) => {
181    const id = await $.session.id();
182    // Unix socket paths are capped near 104 bytes, so /tmp rather than the plugin's folder.
183    sock = `/tmp/eval-kernel-${id.slice(0, 8)}-${Math.random().toString(36).slice(2, 8)}.sock`;
184    void supervise($, [bun, `${$.plugin.root}/kernel/kernel.ts`, sock], e.cwd);
185    await $.tool.register({
186      name: TOOL,
187      description: DESCRIPTION,
188      inputSchema: {
189        type: "object",
190        properties: {
191          code: { type: "string", description: "JavaScript to run as one cell" },
192          title: { type: "string", description: "Short label for the cell" },
193          reset: { type: "boolean", description: "Restart the kernel with empty state before running" },
194        },
195        required: ["code"],
196      },
197    });
198    await $.command.register({ name: "eval", description: "eval-kernel kernel: `/eval vars`, `/eval reset`, or `/eval <code>` to run a cell yourself", argumentHint: "vars | reset | <code>" });
199    return next(e);
200  });
201
202  on("tool.call", { tool: TOOL_ID }, async ($, e, next) => {
203    const input = e as unknown as { code?: unknown; reset?: unknown };
204    if (input.reset === true) {
205      await post($, "/reset").catch(() => {});
206      await $.clock.sleep(200);
207    }
208    if (typeof input.code !== "string" || !input.code.trim()) return input.reset === true ? { result: "kernel reset" } : { isError: true, result: "code is required" };
209    const ev = await runCell($, input.code, maxCalls, next.signal);
210    return ev.status === "error" ? { isError: true, result: render(ev) } : { result: render(ev) };
211  });
212
213  // A subagent hands its report back with the SubagentHandback tool; that report answers the
214  // cell's agent() call.
215  // ponytail: matched by hand because SubagentHandback is missing from this build's typed tool list.
216  on("tool.call", async ($, e, next) => {
217    const call = (e.tool as string) === "SubagentHandback" && e.agentId ? agentCalls.get(e.agentId) : undefined;
218    if (e.agentId && call) {
219      agentCalls.delete(e.agentId);
220      const message = (e as unknown as { message?: unknown }).message;
221      const answer = agentAnswer(typeof message === "string" ? message : JSON.stringify(message), call.schema);
222      await post($, "/answer", { callId: call.callId, ...answer }).catch(() => {});
223      // The cell is the recipient: answering the hand-back here keeps it out of the parent's
224      // conversation, where each report would otherwise start a turn of its own.
225      return { result: "Delivered to the eval cell that started you." };
226    }
227    return next(e);
228  });
229
230  // A cell's subagent runs in the background, so its end is also announced to the parent session,
231  // which would spend a turn on each one. The cell already has the answer: drop those notices.
232  on("prompt.submit", async ($, e, next) => {
233    const ids = e.origin.kind === "task-notification" || e.origin.kind === "peer" || e.origin.kind === "peer-send-message" ? noticeAgents(e.text) : [];
234    if (ids.length && ids.every(id => cellAgents.has(id))) return { drop: "eval-kernel: a cell's subagent finished; the cell has its answer" };
235    return next(e);
236  });
237
238  // A subagent that ended without handing back: its final text (if any) answers the call.
239  on("turn.complete", async ($, e, next) => {
240    const call = e.agentId ? agentCalls.get(e.agentId) : undefined;
241    if (e.agentId && call) {
242      agentCalls.delete(e.agentId);
243      const answer = e.reason === "answer" ? agentAnswer(e.answer, call.schema) : { ok: false, error: `subagent ended: ${e.reason}` };
244      await post($, "/answer", { callId: call.callId, ...answer }).catch(() => {});
245    }
246    return next(e);
247  });
248
249  on("command.run", { command: "eval" }, async ($, e, next) => {
250    const arg = e.args.trim();
251    if (!(await ready($))) return { text: `kernel not running at ${sock} (runtime: ${bun})`, exitCode: 1 };
252    if (arg === "" || arg === "vars") {
253      const res = await $.http.fetch("http://localhost/vars", { socketPath: sock });
254      const vars = (JSON.parse(res.text).vars as { name: string; type: string }[]).map(v => `${v.name}: ${v.type}`);
255      return { text: vars.length ? vars.join("\n") : "no variables yet" };
256    }
257    if (arg === "reset") {
258      await post($, "/reset").catch(() => {});
259      return { text: "kernel reset" };
260    }
261    const ev = await runCell($, arg, maxCalls, next.signal);
262    return { text: render(ev), exitCode: ev.status === "error" ? 1 : 0 };
263  });
264};
265