oh-my-pi compaction methods: shake (move old tool output to files, no model call) and handoff (continue from a handoff document). Use `/compact shake` or…

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 111 lines1import type { EngineInterface, Register, SessionCompactResult, SessionMessage } from "claude-code";
2import { messageTokens, shake } from "./shake";
3
4// compact-methods: oh-my-pi's shake and handoff as Claude Code compaction methods.
5// `/compact shake` and `/compact handoff [focus]` pick one by name (the built-in /compact passes its
6// text through as `instructions`); `autoMethod` picks what runs when the context fills. There are
7// no /shake or /handoff commands: a command hook may not start a compaction.
8
9// oh-my-pi's prompts (agent/src/compaction/prompts/handoff-document.md, handoff-summary-context.md).
10const HANDOFF_PROMPT = `<critical>
11Write a handoff document for another instance of yourself.
12The handoff MUST be sufficient for seamless continuation without access to this conversation.
13Output ONLY the handoff document. No preamble, no commentary, no wrapper text.
14</critical>
15
16<instruction>
17Capture exact technical state, not abstractions.
18- File paths, symbol names, commands run
19- Test results, observed failures
20- Decisions made
21- Partial work affecting the next step
22Register: address the successor directly in the imperative ("Fix X", "Run Y") — never first person ("I need to…", "my attempt…").
23The handoff mechanism is invisible to the document: NEVER list writing, generating, or delivering a handoff/summary/context document as progress or a next step. Progress and Next Steps cover the user's task only.
24</instruction>
25
26<output>
27Use exactly this structure:
28
29## Goal
30## Constraints & Preferences
31## Progress
32### Done
33### In Progress
34### Pending
35## Key Decisions
36## Critical Context
37## Next Steps
38</output>`;
39
40const handoffContext = (doc: string) => `Context replaced. The <handoff> below is a handoff document a prior instance of you wrote from the full conversation. It is your own working memory, not user input.
41- First person inside it refers to you (the prior instance).
42- "Next Steps" is your own resumed plan; re-check it against the latest user message before acting.
43- The handoff already exists and is complete: NEVER write another handoff document unless the user explicitly asks.
44MUST build on prior work; NEVER duplicate prior work.
45
46<handoff>
47${doc}
48</handoff>`;
49
50const total = (ms: readonly SessionMessage[]) => ms.reduce((n, m) => n + messageTokens(m), 0);
51
52async function stateDir($: EngineInterface): Promise<string> {
53 return `${(await $.env.get("HOME")) ?? "/tmp"}/.claude/compact-methods/${await $.session.id()}`;
54}
55
56async function runShake($: EngineInterface, messages: readonly SessionMessage[], protectTokens: number): Promise<{ result: SessionCompactResult; saved: number }> {
57 const r = shake(messages, protectTokens, await stateDir($));
58 for (const o of r.offloads) await $.fs.write(o.path, o.text);
59 return { result: { messages: r.messages, tokensBefore: total(messages), tokensAfter: total(r.messages) }, saved: r.saved };
60}
61
62/** The conversation as plain text, for a handoff written without the prompt cache. */
63function transcript(messages: readonly SessionMessage[]): string {
64 const cap = (t: string, n: number) => (t.length > n ? `${t.slice(0, n)} …[${t.length - n} more]` : t);
65 return messages
66 .map(m => [
67 m.text && `${m.role}: ${m.text}`,
68 ...m.toolUses.map(u => `${m.role} called ${u.tool}(${cap(JSON.stringify(u.input), 300)})${u.text ? ` -> ${cap(u.text, 1500)}` : ""}`),
69 ].filter(Boolean).join("\n"))
70 .filter(Boolean)
71 .join("\n\n")
72 .slice(-200_000);
73}
74
75async function runHandoff($: EngineInterface, messages: readonly SessionMessage[], focus: string): Promise<SessionCompactResult | undefined> {
76 const ask = focus ? `${HANDOFF_PROMPT}\n\n<instruction>\nAdditional focus: ${focus}\n</instruction>` : HANDOFF_PROMPT;
77 // Fork first: it asks over the main thread's last request, so the prompt cache serves the
78 // prefix. With no request to fork (a session just resumed), write it from the messages.
79 let r = await $.model.fork({ prompt: ask });
80 if (!r.isAnswered) r = await $.model.complete({ model: await $.session.model(), system: ask, prompt: `<conversation>\n${transcript(messages)}\n</conversation>\n\nWrite the handoff document now.`, maxTokens: 8000 });
81 if (!r.isAnswered || !r.text.trim()) return undefined;
82 await $.fs.write(`${await stateDir($)}/handoff-${await $.clock.now()}.md`, r.text);
83 return { messages: [{ role: "user", text: handoffContext(r.text.trim()), toolUses: [] }], usage: r.usage };
84}
85
86export const register: Register = (on, options) => {
87 const autoMethod = String(options.autoMethod ?? "native");
88 const protectTokens = Number(options.protectTokens ?? 16000);
89 const minSavings = Number(options.minSavings ?? 4000);
90
91 on("session.compact", async ($, e, next) => {
92 if (e.trigger === "precompute" || e.agentId) return next(e);
93 const named = /^\s*(shake|handoff)\b\s*([\s\S]*)$/.exec(e.instructions ?? "");
94 const method = named ? named[1]! : e.trigger === "auto" ? autoMethod : "native";
95 if (method === "shake") {
96 // A shake the person asked for keeps only a small tail, as oh-my-pi's manual /shake does.
97 const { result, saved } = await runShake($, e.messages, named ? Math.min(protectTokens, 4000) : protectTokens);
98 if (!named && saved < minSavings) return next(e);
99 $.ui.toast(`compact-methods: shake saved ~${saved} tokens`);
100 return saved > 0 ? result : { skip: "nothing large enough to shake" };
101 }
102 if (method === "handoff") {
103 const result = await runHandoff($, e.messages, named?.[2]?.trim() ?? "");
104 if (result) return result;
105 $.ui.toast("compact-methods: handoff failed; using native compaction");
106 return next({ ...e, instructions: named?.[2]?.trim() || undefined });
107 }
108 return next(e);
109 });
110};
111hooks/shake.ts 63 lines1// Shake, after oh-my-pi's compaction/shake.ts: drop heavy, recoverable content out of the live
2// context with no model call. Tool output older than the protected tail is moved to a file and
3// replaced by a one-line pointer the model can Read back.
4//
5// ponytail: tool output only, sized by chars/4. oh-my-pi also elides large fenced/XML blocks in
6// message text and uses a tokenizer. Upgrade path: same pass over `text` for ``` and <tag> blocks.
7
8import type { SessionMessage } from "claude-code";
9
10export type Offload = { id: string; path: string; text: string };
11
12const tokens = (s: string) => Math.ceil(s.length / 4);
13const MIN_TOKENS = 400;
14
15export function messageTokens(m: SessionMessage): number {
16 let n = tokens(m.text);
17 for (const u of m.toolUses) n += tokens(JSON.stringify(u.input ?? {})) + tokens(u.text ?? "");
18 for (const r of m.toolResults ?? []) n += tokens(r.text);
19 return n;
20}
21
22/**
23 * Shake `messages`, keeping the newest `protectTokens` intact. `dir` is where offloaded text will
24 * be written (by the caller); the pointers name files under it.
25 */
26export function shake(messages: readonly SessionMessage[], protectTokens: number, dir: string): { messages: SessionMessage[]; offloads: Offload[]; saved: number } {
27 let tail = 0;
28 let cut = messages.length;
29 while (cut > 0 && tail + messageTokens(messages[cut - 1]!) <= protectTokens) tail += messageTokens(messages[--cut]!);
30
31 const offloads: Offload[] = [];
32 let saved = 0;
33 const pointer = (id: string, text: string) => {
34 const path = `${dir}/${id}.txt`;
35 if (!offloads.some(o => o.id === id)) offloads.push({ id, path, text });
36 const note = `[shaken: ${text.length} characters of tool output moved to ${path}; Read it if you need it]`;
37 saved += tokens(text) - tokens(note);
38 return note;
39 };
40
41 const out = messages.map((m, i) => {
42 if (i >= cut) return m;
43 let changed = false;
44 const toolUses = m.toolUses.map(u => {
45 if (!u.text || tokens(u.text) < MIN_TOKENS) return u;
46 changed = true;
47 const { result: _drop, ...rest } = u;
48 return { ...rest, text: pointer(u.tool_use_id, u.text) };
49 });
50 const toolResults = m.toolResults?.map(r => {
51 if (tokens(r.text) < MIN_TOKENS) return r;
52 changed = true;
53 const { result: _drop, ...rest } = r;
54 return { ...rest, text: pointer(r.tool_use_id, r.text) };
55 });
56 if (!changed) return m;
57 // Without its handle the engine rebuilds the message from role, text and the tool blocks.
58 const { handle: _handle, ...plain } = m;
59 return { ...plain, toolUses, ...(toolResults ? { toolResults } : {}) };
60 });
61 return { messages: out, offloads, saved };
62}
63