Unexpected stops: when a turn ends right after saying it will act, the decision model spots it and one turn resumes the work.

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.ts 53 lines1import type { EngineInterface, Register } from "claude-code";
2
3type Question = Parameters<EngineInterface["decision"]["ask"]>[0]["questions"][string];
4
5// resume-on-stop: a main-thread turn whose last step was text only is asked about: did it say it would
6// act and then stop? A yes starts one turn telling it to carry on, at most once per prompt.
7// Question copied from oh-my-pi (session/unexpected-stop-classifier.ts).
8
9export const UNEXPECTED_STOP: Question = {
10 type: "noul",
11 instructions: "Classify whether this assistant message is an unexpected stop: it says it will act, continue working, or call a tool, then ends without doing so. A message that ends by asking the user to decide or answer is waiting for them, not an unexpected stop.",
12 criteria: {
13 true: 'Unexpected stops:\n- "I should do the same for the JS eval worker. Doing that now."\n- "Let me run the tests next."\n- "I\'ll fix that now."',
14 false: 'Not an unexpected stop:\n- "I\'ve completed the task."\n- "Is there anything else I can help with?"\n- "The fix is done and tests pass."\n- "Should I go ahead with option A or B?" (a question that waits for the user\'s answer)',
15 },
16};
17const RESUME_TEXT = "You ended the turn right after saying you would act. Carry on with what you said you would do.";
18
19export const register: Register = (on, options) => {
20 const threshold = Number(options.threshold ?? 0.5);
21 let last: { turnId: string; answer: string; isTextOnly: boolean } | undefined;
22 let resumes = 0;
23
24 on("prompt.submit", async ($, e, next) => {
25 if (e.origin?.kind !== "plugin") resumes = 0;
26 return next(e);
27 });
28
29 on("turn.step", async function* ($, e, next) {
30 const r = yield* next(e);
31 if (!e.agentId) last = { turnId: e.turnId, answer: r.answer, isTextOnly: r.toolUses.length === 0 && r.stopReason === "end_turn" };
32 return r;
33 });
34
35 on("turn.complete", async ($, e, next) => {
36 const done = await next(e);
37 const step = last;
38 if (e.agentId || e.reason !== "answer" || e.isAborted || !step || step.turnId !== e.turnId || !step.isTextOnly || !step.answer.trim() || resumes >= 1) return done;
39 try {
40 const r = await $.decision.ask({ purpose: "stop", state: step.answer.slice(-2000), questions: { stop: UNEXPECTED_STOP } });
41 const a = r.answers.stop;
42 if (a?.type === "noul" && a.noul >= threshold) {
43 resumes++;
44 $.ui.toast("resume-on-stop: the turn stopped mid-promise; resuming");
45 void $.prompt.submit({ text: RESUME_TEXT }).catch(() => {});
46 }
47 } catch {
48 // Recorded in /decision's log; the turn just ends.
49 }
50 return done;
51 });
52};
53