When context outgrows its shell, shed it: save state to .state/, compact in place, keep going. One criterion, one call, no new session.

When context outgrows its shell, shed it into the folder and keep going.
A Claude Code mod. Two criteria, one call, no new session, nothing to remember.
If your Claude Code usage looks like this, the cache is not your problem:
| Tokens, one project, one week | 158.7M |
| Cache hit rate | 96% |
| Cache read | 152.2M |
| Cache write | 5M |
| Uncached input | 0.7M |
| Output | 0.8M |
That is a healthy cache. Only 4% of input missed it. Yet 96% of all tokens were cache reads, and cache reads are what you pay for a long thread: every API call resends the whole conversation. A hit is cheaper than a miss, but half a million tokens of hits per call, hundreds of times a day, is still the bill.
Parsing the session transcripts (~/.claude/projects/<project>/*.jsonl, one usage per assistant message) found where it went. One thread had been kept open for seven days:
| The seven-day thread | |
|---|---|
| API calls | 618 |
| Average context per call | ~480k tokens |
| Peak context | 966k of a 1M window |
| Cache reads | 296M |
| Full cache rewrites | 14, each 550k to 850k tokens |
| Subagents spawned, all on the most expensive model | 61 |
Two mechanisms did the damage.
1. Thread length. Every tool call re-read ~480k tokens. A fresh session costs ~70k tokens of system prompt, tools, skills and memory. Resuming the thread cost 480k on the first message and on every call after it. The second-largest thread, same setup, averaged 230k per call and cost a quarter as much. Spend tracks thread length.
2. The cache expiring while you sleep. The prompt cache lives one hour on a subscription (five minutes on an API key). Every morning's first message rewrote the entire context at full write price. Fourteen such rewrites, 550k to 850k tokens each, every one after an idle gap over an hour. Auto-compact never helped: on a 1M-window model it fires at about 967k by default.
What it was not: the personal harness, hooks, voice calls and planning rituals accounted for under 4% of the tokens. Uninstalling them would have changed nothing. Measure before you blame your tooling.
/autocompact 150k sets the auto-compact window per model and saves it./clear between unrelated tasks. /compact <focus> at natural breaks.CLAUDE_CODE_SUBAGENT_MODEL=sonnet in settings.json env: subagents inherit the main model unless told otherwise.CLAUDE_CODE_PROMPT_CACHE_TTL=1h where available./context and /usage to see what fills the window and what hits the cache.Those fix the size. None of them fixes the idle rewrite, and all of them need you to remember. Molt is the part that runs itself.
Two criteria, one call.
threshold to a number to pin it instead.The call is /molt. The model saves state to .state/; the mod then compacts the session in place with instructions to keep only what the handoff does not cover. Same session, same prompt box, next call carries a fraction of the tokens.
.state/
INDEX.md routing table: one line per note, says WHEN to read it (cap 200)
HANDOFF.md where the last stretch stopped (cap 60; rewritten by each molt)
log.md append-only history; grep it, never load it whole
notes/*.md decisions, failures, patterns; frontmatter links/files form the graph
INDEX.md and a fresh HANDOFF.md ride the first user message next to CLAUDE.md. A HANDOFF older than 7 days is marked stale, not trusted. The folder is harness-agnostic: any agent that reads markdown can continue from it.
Paste this into Claude Code and let the agent do it:
Install the molt mod for Claude Code:
1. claude plugin marketplace add wisbech/molt
2. claude plugin install molt@molt
3. If the build says hooks modules are early access, add "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" under "env" in ~/.claude/settings.json.
Then tell me whether /molt shows up in the command list.
By hand:
claude plugin marketplace add wisbech/molt
claude plugin install molt@molt
Or for one launch, without installing: claude --plugin-dir /path/to/molt.
Site: https://wisbech.github.io/molt/ · Tests: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .
ctx 132k · molts at 120k (solved: …) and a Molt now button./molt [note] molts now, with a note for the next stretch./molt-lint is free deterministic housekeeping: index cap, orphans, broken links, superseded notes still routed, notes about deleted files, stale handoff. No model turn./molt-graph prints note -> links. Also free.idleMinutes (default 50; set to your cache TTL minus a margin), threshold (empty: solved; a number pins it), auto (off: button and command only).python3 - <<'EOF'
import json, glob, os
d = os.path.expanduser('~/.claude/projects/')
for f in sorted(glob.glob(d + '*/*.jsonl'), key=os.path.getsize, reverse=True)[:5]:
seen, n, cr, cw, mx = set(), 0, 0, 0, 0
for line in open(f):
try: o = json.loads(line)
except: continue
m = o.get('message') or {}; u = m.get('usage') or {}
if o.get('type') != 'assistant' or not u or m.get('id') in seen: continue
seen.add(m.get('id')); n += 1
ctx = u.get('input_tokens', 0) + u.get('cache_read_input_tokens', 0) + u.get('cache_creation_input_tokens', 0)
cr += u.get('cache_read_input_tokens', 0); cw += u.get('cache_creation_input_tokens', 0); mx = max(mx, ctx)
print(f"{os.path.basename(f)[:8]} calls={n} avg_ctx={(cr+cw)/max(n,1)/1e3:.0f}k peak={mx/1e3:.0f}k cache_read={cr/1e6:.0f}M cache_write={cw/1e6:.1f}M")
EOF
If avg_ctx is a few hundred k, you have the problem above.
hooks/register.tsx 350 lines1// molt: when context outgrows its shell, shed it into the folder and keep going.
2//
3// Criteria, in order of what they save:
4// 1. idle: the prompt cache expires after an hour idle, and the first call after that
5// re-writes the whole context at full price. So 50 minutes into an idle stretch,
6// while the cache is still warm and reads are cheap, molt; the rewrite on return
7// is then of a small context. This is where the money went in the traces.
8// 2. size: context tokens >= the ceiling, the backstop. The ceiling is solved, not set:
9// over a stretch between molts, tokens per call ≈ (F + T)/2 + m·T·g/(T − F), where
10// F is the floor (context right after a molt), g the growth per API call and m the
11// reads a molt itself costs. Its minimum is T = F + sqrt(2·m·g·F). Molt measures
12// F, g and m from usage and re-solves after every turn; `threshold` pins it instead.
13// Call: the /molt command (commands/molt.md) has the model save state to .state/,
14// then this module compacts the session in place. Same session, small context.
15//
16// .state/INDEX.md routing table, one line per note, says WHEN to read it (cap 200)
17// .state/HANDOFF.md where the last stretch stopped (cap 60, rewritten by each molt)
18// .state/log.md append-only history, greppable, never injected whole
19// .state/notes/*.md decisions, failures, patterns; frontmatter links/files = the graph
20import type { EngineInterface as Engine, Register } from 'claude-code'
21
22const DIR = '.state'
23const INDEX_CAP = 200
24const HANDOFF_CAP = 60
25const STALE_DAYS = 7
26const DAY = 86_400_000
27const IDLE_FLOOR = 50_000 // below this a fresh session costs about the same; nothing to shed
28
29type Note = { id: string; path: string; title: string; status: string; links: string[]; files: string[] }
30type Lint = { problems: string[]; notes: Note[]; indexLines: number }
31
32// Module state. A reload starts it over, which is fine: it is all derivable.
33const S = {
34 section: null as string | null, // the context block; re-read after every molt
35 ctx: 0,
36 phase: 'idle' as 'idle' | 'shedding',
37 molts: 0,
38 last: '',
39 idleTimer: null as { cancel: () => void } | null,
40 // the sweet-spot inputs
41 floor: 0, // F: context at the first turn after a start or a molt
42 isFloorPending: true,
43 growth: 5_000, // g: tokens added per API call, EMA
44 moltReads: 3, // m: reads one molt costs (the handoff turn's calls + the compaction), EMA
45 ceiling: 150_000, // T: solved, or pinned by the threshold option
46}
47
48const k = (n: number) => `${Math.round(n / 1000)}k`
49const ema = (old: number, next: number, a = 0.3) => old + a * (next - old)
50
51// Tokens read this turn across all its API calls, and how many calls that was.
52function turnReads(usage: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } | undefined, ctxBefore: number, ctxAfter: number) {
53 if (!usage) return null
54 const reads = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
55 const avgCtx = Math.max(1, (Math.max(ctxBefore, 1) + Math.max(ctxAfter, 1)) / 2)
56 return { reads, calls: Math.max(1, Math.round(reads / avgCtx)) }
57}
58
59// T = F + sqrt(2·m·g·F), kept inside sane bounds and rounded to 5k.
60export function solveCeiling(floor: number, growth: number, moltReads: number): number {
61 const f = Math.max(20_000, floor)
62 const t = f + Math.sqrt(2 * Math.max(1, moltReads) * Math.max(500, growth) * f)
63 return Math.round(Math.min(400_000, Math.max(f + 20_000, t)) / 5_000) * 5_000
64}
65
66function frontmatter(text: string): Record<string, string> {
67 const m = /^---\n([\s\S]*?)\n---/.exec(text)
68 const out: Record<string, string> = {}
69 for (const line of (m?.[1] ?? '').split('\n')) {
70 const i = line.indexOf(':')
71 if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim()
72 }
73 return out
74}
75
76// `links: [a, b]`, `links: a, b`, plus [[wikilinks]] in the body
77function listOf(value: string | undefined, body: string, wiki: boolean): string[] {
78 const items = (value ?? '')
79 .replace(/^\[|\]$/g, '')
80 .split(/[,\s]+/)
81 .map(s => s.trim().replace(/^["']|["']$/g, ''))
82 .filter(Boolean)
83 if (wiki) for (const w of body.matchAll(/\[\[([^\]|#]+)/g)) items.push((w[1] ?? '').trim())
84 return [...new Set(items.filter(Boolean))]
85}
86
87async function readText($: Engine, path: string): Promise<string> {
88 return String(await $.fs.read(path))
89}
90
91async function readCapped($: Engine, path: string, cap: number): Promise<string | null> {
92 if (!(await $.fs.exists(path))) return null
93 const lines = (await readText($, path)).split('\n')
94 if (lines.length <= cap) return lines.join('\n')
95 return `${lines.slice(0, cap).join('\n')}\n… ${lines.length - cap} more lines cut. Keep this file under ${cap} lines.`
96}
97
98async function ageDays($: Engine, path: string): Promise<number> {
99 return ((await $.clock.now()) - (await $.fs.stat(path)).mtimeMs) / DAY
100}
101
102async function readNotes($: Engine): Promise<Note[]> {
103 if (!(await $.fs.exists(`${DIR}/notes`))) return []
104 const notes: Note[] = []
105 for (const entry of await $.fs.list(`${DIR}/notes`)) {
106 if (entry.kind !== 'file' || !entry.name.endsWith('.md')) continue
107 const text = await readText($, `${DIR}/notes/${entry.name}`)
108 const fm = frontmatter(text)
109 const id = entry.name.replace(/\.md$/, '')
110 notes.push({
111 id,
112 path: `notes/${entry.name}`,
113 title: fm.title ?? id,
114 status: fm.status ?? 'active',
115 links: listOf(fm.links, text, true).map(l => l.replace(/\.md$/, '').replace(/^notes\//, '')),
116 files: listOf(fm.files, '', false),
117 })
118 }
119 return notes
120}
121
122// Deterministic housekeeping: no model, no tokens.
123async function lint($: Engine): Promise<Lint> {
124 const problems: string[] = []
125 const notes = await readNotes($)
126 const ids = new Set(notes.map(n => n.id))
127 const index = (await $.fs.exists(`${DIR}/INDEX.md`)) ? await readText($, `${DIR}/INDEX.md`) : null
128 const indexLines = index ? index.split('\n').filter((l: string) => l.trim()).length : 0
129
130 if (index === null) {
131 if (notes.length) problems.push(`no ${DIR}/INDEX.md but ${notes.length} note(s): nothing routes to them`)
132 } else {
133 if (indexLines > INDEX_CAP) problems.push(`INDEX.md has ${indexLines} lines, cap is ${INDEX_CAP}: merge or archive`)
134 for (const m of index.matchAll(/\]\(([^)]+\.md)\)/g)) {
135 const target = (m[1] ?? '').replace(/^\.\//, '')
136 if (!(await $.fs.exists(`${DIR}/${target}`))) problems.push(`INDEX.md links to missing ${target}`)
137 }
138 for (const n of notes) {
139 const routed = index.includes(n.path)
140 if (!routed && n.status === 'active') problems.push(`orphan: ${n.path} is not in INDEX.md`)
141 if (routed && n.status !== 'active') problems.push(`${n.path} is ${n.status} but still in INDEX.md`)
142 }
143 }
144 for (const n of notes) {
145 for (const l of n.links) if (!ids.has(l)) problems.push(`${n.path} links to unknown note "${l}"`)
146 for (const f of n.files) if (!(await $.fs.exists(f))) problems.push(`${n.path} is about ${f}, which no longer exists`)
147 }
148 if (await $.fs.exists(`${DIR}/HANDOFF.md`)) {
149 const age = await ageDays($, `${DIR}/HANDOFF.md`)
150 if (age > STALE_DAYS) problems.push(`HANDOFF.md is ${Math.round(age)} days old: verify against git log before trusting it`)
151 }
152 return { problems, notes, indexLines }
153}
154
155// What a fresh stretch reads: the routing table and where the last one stopped.
156async function compose($: Engine): Promise<string | null> {
157 const index = await readCapped($, `${DIR}/INDEX.md`, INDEX_CAP)
158 let handoff: string | null = null
159 if (await $.fs.exists(`${DIR}/HANDOFF.md`)) {
160 const age = await ageDays($, `${DIR}/HANDOFF.md`)
161 handoff =
162 age <= STALE_DAYS
163 ? await readCapped($, `${DIR}/HANDOFF.md`, HANDOFF_CAP + 20)
164 : `(${Math.round(age)} days old, stale. Check git log before trusting it.)`
165 }
166 if (index === null && handoff === null) return null
167 return [
168 `The folder is the state. ${DIR}/INDEX.md is the routing table: one line per note saying when to read it; open a note only when its line applies. ${DIR}/HANDOFF.md is where the last stretch stopped. ${DIR}/log.md is append-only history; grep it, never load it whole. When context grows past the threshold or the session idles, it molts: /molt saves state here, then the session compacts.`,
169 index !== null ? `## ${DIR}/INDEX.md\n${index}` : '',
170 handoff !== null ? `## ${DIR}/HANDOFF.md\n${handoff}` : '',
171 ]
172 .filter(Boolean)
173 .join('\n\n')
174}
175
176const COMPACT_INSTRUCTIONS = `The project state was just saved to ${DIR}/HANDOFF.md and ${DIR}/INDEX.md, which the conversation carries. Keep only: the user's latest request, what is in progress right now, files currently being edited, and any tool result the very next step needs. Drop everything the handoff covers.`
177
178// Shedding runs as its own turn once the session is idle; a timer outlives the dispatch.
179function shed($: Engine): void {
180 if (S.phase === 'shedding') return
181 S.phase = 'shedding'
182 S.idleTimer?.cancel()
183 S.idleTimer = null
184 $.ui.invalidate('ui.render')
185 $.clock.after(1, () => void $.prompt.submit({ text: '/molt' }))
186}
187
188// The free answers: lint and graph never touch the model.
189async function answer($: Engine, what: 'lint' | 'graph', ceiling: string): Promise<{ text: string }> {
190 const { problems, notes, indexLines } = await lint($)
191 if (what === 'graph') return { text: graphText(notes) }
192 const head = `${DIR}/: ${notes.length} note(s), INDEX.md ${indexLines}/${INDEX_CAP} lines, context ${k(S.ctx)}, molts ${S.molts}, ceiling ${ceiling}`
193 return { text: problems.length ? `${head}\n${problems.map(p => `- ${p}`).join('\n')}` : `${head}\nclean.` }
194}
195
196// The model is about to save state; the turn that follows ends in a compaction.
197function shedding($: Engine): void {
198 S.phase = 'shedding'
199 S.idleTimer?.cancel()
200 S.idleTimer = null
201 $.ui.invalidate('ui.render')
202}
203
204function graphText(notes: Note[]): string {
205 if (!notes.length) return `${DIR}/notes/ is empty.`
206 return notes
207 .map(
208 n =>
209 `${n.id}${n.status !== 'active' ? ` (${n.status})` : ''} -> ${n.links.join(', ') || '(no links)'}${n.files.length ? ` files: ${n.files.join(', ')}` : ''}`,
210 )
211 .join('\n')
212}
213
214export const register: Register = (on, options) => {
215 const pinned = Number(options?.threshold) > 0 ? Math.max(20_000, Number(options?.threshold)) : 0 // 0 = solve it
216 if (pinned) S.ceiling = pinned
217 const idleMinutes = String(options?.idleMinutes ?? '').trim() === '0' ? 0 : Math.max(1, Number(options?.idleMinutes) || 50)
218 const isAuto = options?.auto !== false
219
220 // A new prompt means the person is back: the idle clock starts over at turn.complete.
221 on('prompt.submit', ($, e, next) => {
222 S.idleTimer?.cancel()
223 S.idleTimer = null
224 return next(e)
225 })
226
227 on('session.start', async ($, e, next) => {
228 // Registered commands raise command.run and answer without a model turn, in every mode.
229 // (/molt itself is the plugin's markdown command: a skill the model expands.)
230 await $.command.register({ name: 'molt-lint', description: `Lint ${DIR}/: index cap, orphans, broken links, stale handoff. Free.` })
231 await $.command.register({ name: 'molt-graph', description: `Print ${DIR}/notes links as note -> links. Free.` })
232 // Priors from earlier sessions; this session's floor is measured on its first turn.
233 const g = Number(await $.store.get('growth'))
234 const m = Number(await $.store.get('moltReads'))
235 if (g > 0) S.growth = g
236 if (m > 0) S.moltReads = m
237 S.isFloorPending = true
238 S.section = await compose($)
239 const { problems } = await lint($)
240 if (problems.length) $.ui.toast(`${DIR}: ${problems.length} lint issue(s). Run /molt lint.`)
241 return next(e)
242 })
243
244 // Rides the first user message next to claudeMd; changes only when a molt rewrote the files.
245 on('prompt.context', async ($, e, next) => {
246 const r = await next(e)
247 if (S.section === null) return r
248 return { ...r, blocks: [...r.blocks, { name: 'molt', text: S.section }] }
249 })
250
251 on('turn.complete', async ($, e, next) => {
252 if (e.agentId) return next(e)
253 const ctxBefore = S.ctx
254 const { context } = await $.session.usage()
255 S.ctx = context.tokens ?? 0
256 const t = turnReads(e.usage, ctxBefore, S.ctx)
257
258 if (S.isFloorPending && S.ctx > 0) {
259 S.floor = S.ctx
260 S.isFloorPending = false
261 } else if (S.phase === 'idle' && t && S.ctx > ctxBefore) {
262 S.growth = ema(S.growth, (S.ctx - ctxBefore) / t.calls)
263 void $.store.set('growth', S.growth)
264 }
265
266 if (S.phase === 'shedding') {
267 S.phase = 'idle'
268 if (e.reason === 'answer') {
269 const before = S.ctx
270 if (t) {
271 S.moltReads = ema(S.moltReads, t.calls + 1) // the handoff turn's calls, plus the compaction
272 void $.store.set('moltReads', S.moltReads)
273 }
274 S.section = await compose($)
275 const r = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS })
276 if ('skip' in r) {
277 S.last = `molt: compaction skipped (${r.skip})`
278 } else {
279 S.molts += 1
280 S.ctx = r.tokensAfter ?? S.ctx
281 S.isFloorPending = true // the next turn measures the new floor
282 S.last = `molted ${k(before)} → ${k(S.ctx)}`
283 }
284 $.ui.toast(S.last)
285 } else {
286 S.last = `molt interrupted (${e.reason})`
287 }
288 }
289
290 if (!pinned) S.ceiling = solveCeiling(S.floor || S.ctx, S.growth, S.moltReads)
291
292 if (S.phase === 'idle' && isAuto && S.ctx >= S.ceiling) {
293 shed($)
294 } else if (S.phase === 'idle' && isAuto && idleMinutes > 0 && S.ctx >= IDLE_FLOOR) {
295 // Molt before the cache goes cold, not after.
296 S.idleTimer?.cancel()
297 S.idleTimer = $.clock.after(idleMinutes * 60_000, () => {
298 S.idleTimer = null
299 S.last = `idle ${idleMinutes} min, molting before the cache expires`
300 shed($)
301 })
302 }
303
304 $.ui.status(`ctx ${k(S.ctx)}${S.molts ? ` · molts ${S.molts}` : ''}`)
305 $.ui.invalidate('ui.render')
306 return next(e)
307 })
308
309 // The bar: always there once the mod is loaded, so "is molt on?" has a visible answer.
310 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
311 if (e.props.hasSurvey) return next(e)
312 const { Box, Button, Text } = $.ui.resolve(e)
313 const text =
314 S.phase === 'shedding'
315 ? `molt · saving ${DIR}/HANDOFF.md, then compacting…`
316 : `molt · ctx ${k(S.ctx)} · sheds at ${k(S.ceiling)}${pinned ? '' : ' (solved)'} or ${idleMinutes} min idle${S.molts ? ` · molts ${S.molts}` : ''}${S.last ? ` · ${S.last}` : ''} `
317 return (
318 <Box>
319 <Text dimColor>{text}</Text>
320 {S.phase === 'idle' && <Button key="molt" label="Molt now" onPress={() => shed($)} />}
321 </Box>
322 )
323 })
324
325 const ceilingText = () =>
326 `${k(S.ceiling)}${pinned ? ' (pinned)' : ` (floor ${k(S.floor)}, growth ${k(S.growth)}/call, molt ${S.moltReads.toFixed(1)} reads)`}`
327
328 // Names may come namespaced (molt:molt-lint) from an installed plugin, bare from --plugin-dir.
329 on('command.run', { command: ['molt-lint', 'molt:molt-lint'] }, $ => answer($, 'lint', ceilingText()))
330 on('command.run', { command: ['molt-graph', 'molt:molt-graph'] }, $ => answer($, 'graph', ceilingText()))
331
332 // A molt is starting: the model is about to save state. Fires however /molt was invoked
333 // (typed, submitted by the idle timer or the button, or called through the Skill tool).
334 on('skill.prompt', { skill: ['molt', 'molt:molt'] }, ($, e, next) => {
335 const arg = /The user adds: (\S*)/.exec(e.text)?.[1]
336 if (arg === 'lint' || arg === 'graph') {
337 // Typed as /molt lint where the skill path took it: hand the model the free answer to relay.
338 return answer($, arg, ceilingText()).then(r => ({ text: `Relay this to the user exactly, then stop:\n\n${r.text}` }))
339 }
340 shedding($)
341 return next(e)
342 })
343 on('command.run', { command: ['molt', 'molt:molt'] }, ($, e, next) => {
344 const arg = e.args.trim()
345 if (arg === 'lint' || arg === 'graph') return answer($, arg, ceilingText())
346 shedding($)
347 return next(e)
348 })
349}
350