Claude compacts its own conversation at a clean break, then carries on by itself. A gentle note at 50% context or 300K tokens, whichever comes first, a strict…

<img alt="catnap: a pixel cat asleep on its notes" src="launch/catnap.svg" width="220">
Claude naps at a clean break. Not mid-task.
https://github.com/user-attachments/assets/68464fdd-e1fc-4e91-88a5-a71286eb9c08
finish the thought.
leave a note.
nap.
wake up knowing everything.
Claude Code compacts your conversation when the context window fills up, usually in the middle of a task. catnap gives Claude a heads-up at 50% instead (300K tokens on a 1M window). Claude finishes the piece it is on, writes itself a handoff note, compacts, and carries on by itself. You don't type anything.
claude plugin marketplace add LippInc/catnap
claude plugin install catnap@lippinc
You need Claude Code v2.1.287 or later, or v2.1.286 in the desktop app. The install may say that 9 options are not set yet: that's fine, catnap uses its defaults (see Settings). Inside a terminal session instead: /plugin install catnap --marketplace LippInc/catnap. New sessions load it on their own, including the desktop app's Code tab; a session that is already open needs /reload-plugins first. Only install catnap from LippInc/catnap: everything it hooks and calls is listed under What it can reach.
Try it now: type /catnap:compact. Claude finishes the piece it is on, writes itself a handoff, compacts, and carries on by itself. That is the whole loop, on demand.
After that it runs on its own. You see nothing until the context reaches 50% or 300K tokens, whichever comes first: 100K on a 200K window, 300K on a 1M window, which can take hours. Then Claude finishes its piece, writes a handoff and compacts. The handoff takes the place of Claude Code's summary, so the nap takes well under a second, and a message starting with [catnap] tells Claude to carry on. You don't type anything.
Your own numbers: /catnap:check reads your local Claude Code logs (numbers only; nothing leaves your machine) and starts with a card like this one, from the author's last 30 days. To see yours before installing anything, run npx github:LippInc/catnap in any terminal (Node 18 or later):
/\_/\ zZ 78 of 82 auto-compactions cut Claude off mid-task.
( -.- ) A clean break at 50% would re-read about 17% less context (estimate).
> ^ < catnap check, your last 30 days · github.com/LippInc/catnap
If catnap saves you one "let me re-read where I was", a star helps other people find it.
| | Without catnap | With catnap | | :- | :- | :- | | When it compacts | when the window is nearly full | when Claude finishes the piece it is on, from 50% (300K on a 1M window) | | Compactions in the middle of a task | 78 of 82 auto-compactions in the author's last 30 days of logs; 29 of 29 in the benchmark | 0 of 129 naps: each came after Claude finished its piece and ended its turn (98 in the benchmarks, 31 in the author's own sessions) | | The compaction itself | a model call reads the whole conversation and writes a summary: about 20 seconds a nap in the benchmarks, over a minute in the author's own chats at 500K | no model call: Claude's note stands in place of the summary, in well under a second | | Right after | Claude carries on from Claude Code's summary of the conversation | Claude carries on from the note it wrote at the clean break, word for word, with your messages | | What Claude still knows | asked right after the compaction, Claude named all 20 instructions and facts said in the chat, in each of 5 runs | the same: all 20, in each of 15 runs, from a note less than half the size of the summary (recall test) | | Cost | auto-compact set to cut at the same point | the same, in tokens and in dollars |
<sub>Cost: the same coding tasks run both ways to the end, on 200K and 1M windows (bench/PAIRED.md). Against Claude Code's default point, which on a 1M window cuts near 965K, catnap used 56% fewer tokens (napping at 500K, its 1M line up to 0.3.10); that saving comes from cutting earlier, which a lower auto-compact setting also gets you. Your own history: /catnap:check.</sub>
You set the lines; Claude picks the moment inside them. The mod compacts nothing until Claude has finished its piece and written its note (Claude Code's own auto-compact stays on as the backstop), and one setting turns it all off.
Each line is a percentage of the window and a number of tokens, and whichever comes first counts: 50%, 60% and 70% on a 200K window (100K, 120K, 140K), 300K, 400K and 500K on a 1M window. The token lines are new in 0.3.11. On 1M, napping at 500K meant every call carried about 240K tokens of context on average. Cutting earlier makes each call cheaper: in the 1M benchmark, runs cut near 147K averaged 92K per call and cost less in total, even with several times as many cuts (bench/PAIRED.md). The lines stay 100K apart on 1M because how far Claude runs past a line depends on the size of the piece it is finishing, in tokens.
compact_now this early, catnap answers "not yet" and Claude carries on. You can still nap any time: type /catnap:compact, or ask for a compaction in your own words.compact_now: Claude passes its handoff with the call (or the path of a handoff file your save_step wrote). It makes the call in the same response as its last action (say, the command that runs the tests), so the nap takes no model call of its own; it hasn't seen that action's result yet, so the handoff's next step starts with checking it. When that turn ends, catnap compacts the conversation, and in place of Claude Code's summary it puts Claude's handoff, word for word, with your messages and the skills the conversation used. No model call has to read the whole conversation to summarize it, so the nap takes well under a second. Then it sends Claude a message: "The conversation was just compacted at your request ... Your handoff is above, in place of a summary. Carry on exactly where you left off, with the next step it names." With handoff_summary off, Claude Code writes its summary as usual, told to keep what Claude named, and the message carries the handoff instead.All the lines are settings (below).
| Option | Default | What it does | | :- | :- | :- | | soft_percent | 50 | Where the soft note comes. Set it at or above the strict line to turn the soft note off. | | strict_percent | 60 | Where the strict note comes. | | auto_percent | 70 | Where Claude Code's own auto-compact runs. Keep it above the strict line, or Claude Code compacts before Claude is asked to (the mod shows a toast if it isn't). 0 leaves Claude Code's own setting alone. | | soft_tokens | 300000 | The soft line in tokens. Whichever of this and soft_percent comes first counts. 0 turns it off. | | strict_tokens | 400000 | The strict line in tokens, the same way. | | auto_tokens | 500000 | The auto-compact line in tokens, the same way. To go by percentages only, as up to 0.3.10, set all three to 0. | | save_step | empty | A command Claude runs to save its work before compacting, for example /save-before-compact from save-before-compact. Left empty, Claude passes its handoff to compact_now itself, and catnap keeps a copy (see Files it writes). | | handoff_summary | on | At each nap, Claude's handoff stands in place of Claude Code's summary, with your messages word for word and the skills the conversation used, so no model call summarizes the conversation (in the benchmarks: about 20 seconds and $0.09 a nap on a 200K window, $0.16 on 1M, at API prices). What it gives up: Claude Code's summary as a backup for what the handoff missed; the files Claude read last, which Claude Code attaches again after its own compaction (Claude re-reads what its handoff names instead); and the lists of deferred tools, agents and MCP instructions come back one reply later. Off: Claude Code writes its summary and the handoff comes after it, as up to 0.3.8. | | fresh_start | off | At each nap, clear the conversation instead of compacting it: Claude carries on from its handoff alone, without your earlier messages or the skills it used. Claude Code's summary is not there to catch what the handoff missed. Up to 0.3.8 this saved about three quarters of a nap's tokens; since 0.3.9 a nap makes no summary call by default either, and the difference is what Claude keeps. |
You can change these in /config, or from the shell:
echo '{"save_step": "/save-before-compact", "strict_percent": "65", "auto_percent": "75"}' | claude plugin configure catnap@lippinc --values-stdin
A change there applies to sessions that start after it, or to an open one after /reload-plugins. To move a line in a session that is already running, use config.json (see Files it writes). It is re-read on every check.
CATNAP_OFF=1 (the old name COMPACT_WHEN_READY_OFF=1 still works). Auto-compact is then left as Claude Code has it./plugin.claude plugin uninstall catnap@lippinc. If you installed it under its old name, claude plugin uninstall compact-when-ready@lippinc. Its state folder (see Files it writes) is left behind; delete it if you want it gone.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE in Claude Code's own process, the same variable you could set yourself. While the mod is on, its setting wins over one in your settings files. That one still applies when the mod is off, so it's a good fallback to keep. Turning the mod off in config.json, or setting the line to 0, puts back whatever the variable held before. Programs Claude starts from the session (Bash commands, a nested claude) inherit the value.claude -p runs keep Claude Code's own setting.| Agent | Status | | :- | :- | | Claude Code | the original: a mod, everything above | | OpenCode | a plugin, ports/opencode. Tested live on OpenCode 1.19.0 under opencode serve with a free model; a bare opencode run exits before the compaction, and the TUI is not tested yet | | Gemini CLI | an extension, ports/gemini-cli. Gemini CLI has no compaction a hook can start, so the port clears the context and the model carries on from its own handoff. The full loop ran live twice (Gemini CLI 0.64.0, gemini-3-flash-preview, headless); the interactive screen is not tested |
A port needs four things from the agent's hooks: read the context percentage, add a note to a tool result the model reads, start a compaction (or clear the context) between turns, and submit a prompt after it. ports/opencode and ports/gemini-cli show how. Where it stands elsewhere today: Codex and Cursor hooks can continue a session but cannot start a compaction, so a port would be partial at best; Windsurf has no hook that can continue a session or see context use. Pull requests are welcome.
About 30% less context re-read was measured by replaying the author's 9 real long sessions with the mod against Claude Code's default auto-compact point: 33.0% less. One known bias pushes that up, so we say about 30%. Per session it ran from about 0% (sessions that ended right after compacting) to 46%. Against an auto-compact line already set to 70%, it was 16%. Most of the saving comes from compacting earlier. A lower auto-compact setting does that too, but auto-compact lands mid-task almost every time; this waits for a clean break. These are tokens re-read, not plan usage: on a subscription the saving in usage is smaller, because cache reads weigh less. Script and per-session table: bench/REPLAY.md.
Measured to the end of a task: a paired benchmark ran Opus 5.5 on long synthetic coding tasks until hidden tests passed, and counted every call, compactions included. On a 1M window catnap used 56% fewer tokens than Claude Code's default auto-compact point, about a third less at API prices, napping at 500K (its 1M line up to 0.3.10; since 0.3.11 it naps at 300K, not benchmarked to the end yet). On a 200K window, 17% fewer tokens. That saving comes from compacting earlier. Against auto-compact set to cut at the same point, catnap's totals were within noise on both windows, so the clean break and the handoff showed no measurable extra cost. Since 0.3.8 the handoff rides along with Claude's last action instead of taking a model call of its own: in 6 runs on 200K, 10 of 11 naps did. With fresh_start on, a nap took about a quarter of the tokens of a compaction, which is a few percent of a whole run, too little to show in these totals. Method and tables, in tokens and dollars: bench/PAIRED.md.
No summary call since 0.3.9: up to 0.3.8 every nap ended in Claude Code's compaction, a model call that reads the whole conversation and writes a summary. In the benchmarks that took a median 19.7 seconds and $0.09 at API prices on a 200K window, and $0.16 on 1M; in the author's own desktop chats, at about 500K, 62 to 80 seconds. With the handoff in its place a nap took 16 to 27 milliseconds and no model call. In 6 runs on 200K the median time to finish a task was 406 seconds, against 464 with 0.3.8; the run totals stayed within noise. Every run finished with all tests passing, and after every nap Claude first checked its last action, then went on to the next ticket.
No compaction mid-task with catnap: in the author's own sessions from 2026-10-13 to 10-19, all 31 naps came after Claude's turn had ended, and each one carried on by itself. In the benchmarks, 0 of 98 naps came mid-task, against 29 of 29 auto-compactions (bench/PAIRED.md).
Nothing you said is lost at a nap: in a recall benchmark Claude worked through coding tickets one message at a time while twenty instructions and facts were said in the chat, inside chatty notes, and nowhere else. Asked right after a compaction to list them from memory, without opening files, Claude named all 20 in every run: 15 with catnap, 5 with auto-compact set to cut at the same point. The code it wrote next kept every rule either way. In place of the summary catnap put a median 4.0K tokens, against 10.0K for Claude Code's summary. The benchmark found one thing catnap did worse, fixed in 0.3.10: when a nap rode along with Claude's last action, the reply you were owed on that step went missing on 5 of 22 nap steps (0 of 14 with auto-compact); with 0.3.10, 11 of 11 came through. Method and tables: bench/RECALL.md.
Check your own history in seconds: /catnap:check inside Claude Code (or npx github:LippInc/catnap with nothing installed, or node bench/check.mjs from a clone) reads your local Claude Code logs and prints numbers only (nothing leaves your machine) and tells you how often auto-compact cut in mid-task and what a clean-break compaction would have saved. Your number will differ: it covers all your sessions, short ones included, so it is usually lower than the long-session replay above (17.5% on the author's last 30 days). The author's last 30 days: 78 of 82 auto-compactions landed while Claude was in the middle of a tool loop. That is not bad luck: on the author's machine about 92% of Claude's calls end in a tool call, and auto-compact doesn't wait for a clean break.
compact_now itself, so there is no extra call to end the reply. In return each compaction comes earlier and at a clean break. Measured to the end of a task, catnap's total was within noise of auto-compact cutting at the same point; see Numbers, and /catnap:check for your own history..log.json is the most useful thing you can send.claude plugin uninstall catnap@lippinc. Until then it works today.handoff_summary on (the default), the mod runs /compact between turns, as if you had typed it, and its session.compact hook answers that one compaction with the handoff, so Claude Code's summarizer doesn't run. It goes through /compact because a mod's own direct compaction skips that mod's hooks. Your own PreCompact hooks (a transcript backup, say) still run at a nap: the mod first hands Claude Code the same compaction with a one-message stand-in, the hooks run on it, and Claude Code refuses one message with no model call. A PreCompact hook that blocks stops the nap, as it would stop a /compact you type. Before 0.3.12 a nap skipped those hooks. Other compactions (auto-compact, a /compact you type, a subagent's) get Claude Code's summary as usual, and so does the mod's own when the handoff file can't be read. Where the hook is held back (see Limits), Claude Code's summary stands and the message after it carries the handoff./compact with its instructions, as if you had typed it./compact fails, because it is refused or hasn't compacted within 10 minutes, the mod gives up for that round. It shows a toast and stays quiet until a compaction happens some other way, and auto-compact still applies./compact you type) starts the notes over. Claude Code reports it through the session.compact hook. Where that hook is held back (see Limits), the mod spots it from the context instead: a fall of 20 points or more between two checks, while nothing of its own is queued, counts as a compaction.session.compact hook is held back, a compaction the mod didn't run counts only when the context falls 20 points or more, as above.) Without this, Claude was asked to compact again after every feature (live: a 90K-token CLAUDE.md on a 200K window, two compactions in under a minute and a third queued; with the guard, the same setup compacted once and Claude finished the task). In a normal session a compaction leaves a few percent, so the lines stay where you set them.claude -p runs, including ones started from inside a chat, and SDK sessions with nothing drawn. Subagents get no notes and can't call the tool.CLAUDE_CODE_ENTRYPOINT=claude-desktop). A claude -p started from inside a chat inherits that, but Claude Code also marks it as started from another session (CLAUDECODE, CLAUDE_CODE_CHILD_SESSION), so it stays out.All its files go in ~/.claude/state/catnap/, or in $CLAUDE_STATE_DIR/catnap/ if you set that variable:
<session id>.json is {"pending": true, ...} while a compaction is queued. Other tools may read it: if you have a Stop hook that keeps turns going, make it stay quiet while this file is fresh. Otherwise the turn never ends and the compaction never runs.<session id>.handoff.md is the last handoff Claude passed to compact_now as a note, kept so you can look at it. The message after the compaction gives Claude the same text.<session id>.log.json holds the last 60 events (notes, queued, compacted, gave up) for debugging. Only a person's session gets one, or a session where the mod did something.config.json is optional. It overrides the options live, with no reload: {"soft": 50, "threshold": 60, "auto": 70, "softTokens": 300000, "thresholdTokens": 400000, "autoTokens": 500000, "off": false, "fresh": false, "handoffSummary": true}. threshold is the strict line, fresh is fresh_start, and handoffSummary is handoff_summary.catnap was called compact-when-ready until this rename, and its state folder was ~/.claude/state/compact-when-ready/. Old settings and files in that folder are not read; copy a config.json over to keep it.
catnap is a mod: code that runs with your permissions. These are the calls claude plugin validate . lists, and what each one is for; to check them before you install, clone the repository and run that command. Nothing here leaves your machine: the mod makes no network requests, no model calls of its own, and starts no processes.
session.start reads the settings and starts the mod's timer.tool.call adds the notes to tool results and answers compact_now.prompt.submit checks whether your latest prompt asks for a compaction (the words compact or nap, as in /catnap:compact), so compact_now may run before the line. For handoff_summary it also keeps your prompts' text in memory (the newest, up to 48,000 characters), to put back after a nap. It passes the prompt on unchanged.skill.prompt keeps each skill's text as Claude Code loads it, in memory (catnap's own commands left out), so the ones the conversation used can be put back after a nap. It passes the text on unchanged.turn.start notes which turn is running, so compact_now knows its turn.turn.step answers one model request itself: the one right aftehooks/register.ts 1019 lines1import type { PluginOptions, Register, SessionCompactResult, Timer } from 'claude-code'
2
3// catnap (formerly compact-when-ready): Claude compacts its own conversation at a clean break, then carries on by itself.
4// Two lines on the context window:
5// - SOFT (soft_percent, 50 by default): one note on a tool result: plan a compaction at a comfortable moment, i.e.
6// finish the piece you are on properly (a page, a section), start nothing big, then save, compact_now and end the turn.
7// - STRICT (strict_percent, 60 by default): the strict note, repeated every REMIND_STEP points: save everything now,
8// call compact_now and end the turn. If a turn ends past this line without compact_now, the mod asks for it with a
9// prompt of its own.
10// - AUTO (auto_percent, 70 by default): Claude Code's own auto-compact, the backstop that still compacts mid-turn.
11// The mod sets CLAUDE_AUTOCOMPACT_PCT_OVERRIDE in its own process to this value (the engine reads the variable at
12// each check: moved 70->7 and 7->12 at runtime, live 2026-10-17), so the mod's setting wins over one from the
13// settings files, which stays as the fallback when the mod is off. 0 leaves Claude Code's own setting alone; off
14// (config.json) or 0 puts back what the process had before the mod first set it (kept in CATNAP_AUTO_ORIG,
15// so a reload still knows it). Only in a person's session, like the notes.
16// Each line also has a size in tokens (soft_tokens 300K, strict_tokens 400K, auto_tokens 500K by default; 0 = none),
17// and whichever comes first counts: on a 200K window 50/60/70% (100K/120K/140K) as before, on 1M 300K/400K/500K
18// (0.3.11). Why: what each call costs follows where the context is cut (2026-10-19 1M benchmark: cuts near 490K
19// carried ~240K per call, near 147K ~92K, and the earlier cuts cost less in total), and how far a session runs past
20// a line is the size of the piece it is finishing, in tokens, so the gaps between the lines stay 100K on 1M.
21// A token line is turned into whole percent of the window (rounded down) at every check, so a model switch moves it.
22// "Save" is the save_step option when it is set (a command such as /save-before-compact, which writes a handoff file
23// whose path Claude passes to compact_now), else Claude passes its handoff to compact_now itself, as `note`: one call
24// instead of a file write plus the call. When a turn that called compact_now ends, this mod compacts the session and
25// queues a prompt that makes the model continue, with the handoff's text in it (the note, or the file read back, cut
26// at PASTE_MAX). Asking Claude to read the file was not enough: in 10 of 10 compactions of a 2026-10-18 benchmark it
27// carried on from the compaction summary and never opened it.
28// The model request that would follow compact_now in its turn could only end the reply (the tool result says so), yet
29// it re-reads the whole context; a turn.step hook answers that one request itself with a one-line reply, so the turn
30// ends at once. Only that turn's next request in the main loop, once.
31// The notes and the tool ask for compact_now in the same response as Claude's last action (for example the command
32// that runs the tests), not in a reply of its own, which re-reads the whole context for that one call: up to 0.3.7
33// compact_now came alone in every benchmark nap, right after a call that wrote files and ran the tests (102-108K
34// tokens a nap on a 200K window, 498-512K on 1M). The handoff is then written before that action's result is seen,
35// so its next step starts with checking it. In 6 runs on 200K (2026-10-19), 10 of 11 naps rode along.
36// Below the line (the soft one, or the strict one when the soft line is off) compact_now waits for the note: called
37// on Claude's own it is refused, unless the person's latest prompt asks for a compaction (/catnap:compact, or words
38// like "compact now"). On a 1M window Claude otherwise napped at clean breaks long before 50%: 23 of 28 naps in a
39// 2026-10-19 benchmark, at 12-47%, one run every ~20 tickets. Only in a person's session, like the notes.
40// How it compacts: $.session.compact between turns. A host that refuses that call (the desktop app's Code tab runs the
41// engine as an SDK session: "not available in a headless (-p / SDK) session", seen live 2026-10-13) gets a
42// /compact run through $.command.run instead (also after FALLBACK_AFTER refusals of any kind; a plugin's prompt may
43// not begin with /); the session.compact hook, or else the command's end, marks it landed and the continue follows.
44// If neither works the mod gives up for the cycle: a toast, the queued state cleared, no notes or asks until a
45// compaction lands some other way (auto-compact, a typed /compact).
46// A compaction the mod did not run (auto-compact, a typed /compact) starts the note cycle over. The session.compact
47// hook reports it, except where Claude Code's built-in sec-default mod is seated (managed settings, or a Team or
48// Enterprise organization), which holds that hook back from a person's mods since 2.1.299
49// (anthropics/claude-code#102134). The fallback: context that falls DROP_POINTS or more between two checks, with no
50// compaction of the mod's own queued or running, was compacted some other way.
51// handoff_summary (on by default; the option, or "handoffSummary": false in config.json, turns it off): the
52// session.compact hook answers catnap's own compaction itself, with the handoff in place of Claude Code's summary,
53// the person's prompts word for word under it (newest first, up to PERSON_MAX characters in all), and the skills the
54// conversation used as they were loaded (the skill.prompt hook keeps their text; the engine's own compaction adds
55// them back too, and its other extras, the deferred-tool and agent lists and the MCP instructions, return by
56// themselves one model response later, live 2026-10-19). No model call then summarizes the conversation: that call
57// re-reads the whole context and writes a summary (70 s at 511K tokens in a desktop app chat, 2026-10-19). The
58// continue prompt then only says the handoff is above. With it on catnap always compacts through /compact, which its
59// hook sees (its own $.session.compact skips its hooks); only that compaction, in the main loop, and only when the
60// handoff can be read; anything else goes to the engine, and where the hook is held back the engine's summary stands
61// as before. The person's PreCompact hooks (settings hooks; a transcript backup, say) run inside the engine, beneath
62// next(), so before answering, the hook hands the engine the same compaction with one stand-in message (STAND_IN):
63// the PreCompact hooks run on it, then the engine refuses one message ("Not enough messages to compact.") with no
64// model call (live 2.1.304, 2026-10-22). A PreCompact hook that blocks is honoured: its skip is the answer, and no
65// handoff lands. An engine that summarizes the stand-in instead costs one small model call, logged; its summary is
66// dropped. Before 0.3.12 a nap skipped those hooks.
67// After any compaction the first percent seen is the floor: for that cycle both lines sit at least FLOOR_GAIN points
68// above it (logged once a cycle, one toast a session when that moves a line), so a session whose fixed part is near
69// the lines is not asked to compact again and again. The auto line stays as set.
70//
71// State folder = <state>/catnap (NAME), <state> = CLAUDE_STATE_DIR, else ~/.claude/state.
72// Config: the userConfig options (soft_percent, strict_percent, auto_percent, soft_tokens, strict_tokens, auto_tokens,
73// save_step, fresh_start, handoff_summary), then config.json in the state folder ({"soft": 50, "threshold": 60,
74// "auto": 70, "softTokens": 300000, "thresholdTokens": 400000, "autoTokens": 500000, "off": false, "fresh": false,
75// "handoffSummary": true}, re-read on every check, no reload needed), then a
76// per-session <session id>.config.json beside it; each later one wins. A soft line at or above the strict line is off;
77// an auto line at or below the strict line gets one toast, since auto-compact then runs before the mod can ask.
78// Pending flag: <session id>.json = {"pending": true, ...}. A Stop hook of yours that keeps a turn going should stay
79// silent while it is fresh, so the turn really ends and the compaction can run.
80// Log: <session id>.log.json, the last 60 events; written only for a person's session or one that did something.
81// Acts only in a person's session: one where something draws (a terminal), or a desktop app chat. The app runs the
82// engine as an SDK session whose surface list is empty until a window attaches and can empty again mid-turn (a long
83// turn there crossed 50% and 60% with no note, live 2026-10-17), so its chats are known by CLAUDE_CODE_ENTRYPOINT =
84// claude-desktop instead. A `claude -p` started from inside such a chat inherits that entry point, but also CLAUDECODE
85// and CLAUDE_CODE_CHILD_SESSION, which the engine sets only for the processes it starts (its own nested-session
86// test); with either one set it is not a person's session. A plain -p run is untouched, and so is any process with
87// CATNAP_OFF set, or the old COMPACT_WHEN_READY_OFF (or the author's wrapper markers CLAUDE_DELEGATE_LEG / CLAUDE_HARNESS_PROBE).
88// Deferred work (the compaction, the prompts) runs on one timer started in session.start: $ is only ever spelled at
89// its call site inside a hook, so the deferred steps are flags this timer picks up.
90
91// The plugin's name (.claude-plugin/plugin.json "name"). The default tool name, the state folder and the "[NAME]" prefix
92// of every note are built from it. The ONE exception is the tool.call matcher further down, which has to be a regex
93// literal: `claude plugin validate` and the directory's checks read it statically and show tool=? for anything built at
94// run time. So a rename touches two places: this line and that literal. hooks/catnap.test.ts fails if they disagree
95// with each other or with plugin.json (the notes tell Claude the name the engine gives the plugin, and the test calls
96// that name and expects the mod to answer).
97export const NAME = 'catnap'
98const TOOL = 'compact_now'
99// the tool's full name as the engine registers it for a plugin of that name
100export const toolNameFor = (plugin: string): string => `mcp__${plugin}__${TOOL}`
101const DEFAULT_SOFT = 50
102const DEFAULT_THRESHOLD = 60
103const DEFAULT_AUTO = 70
104const DEFAULT_SOFT_TOKENS = 300_000
105const DEFAULT_THRESHOLD_TOKENS = 400_000
106const DEFAULT_AUTO_TOKENS = 500_000
107const REMIND_STEP = 2 // percentage points between repeated mid-turn notes
108const ASK_STEP = 5 // percentage points before an ignored turn-end ask is repeated
109const TICK_MS = 1500 // how often the timer looks for deferred work
110const FALLBACK_AFTER = 3 // direct compactions refused in a row before /compact is run instead
111const SLASH_TIMEOUT_MS = 10 * 60_000 // a /compact run that has not landed by then is given up on
112// a fall this big between two checks is a compaction: a cycle only has state from the soft line (50%) on, and a
113// compaction leaves a few percent (live: 257K->29K, 531K->28K, 66K->7K tokens; on 2.1.302 an auto-compact went from
114// 38% to 8% at the next check, the messages it keeps included), while plain work only adds context
115const DROP_POINTS = 20
116// how far above the floor the lines sit at least: a compaction normally leaves a few percent, so the lines stand as
117// set; where the session's fixed part (system prompt, tools, CLAUDE.md) is itself near the lines, a compaction frees
118// almost nothing and each note would ask for another one (live 2026-10-18: a 90K-token CLAUDE.md on a 200K window left
119// 50-51% after each compaction, and Claude compacted twice in under a minute and queued a third)
120const FLOOR_GAIN = 10
121// the most of a handoff the continue prompt carries (about 8K tokens); a longer one is cut there, and the prompt says
122// where the rest is
123export const PASTE_MAX = 32_000
124// handoff_summary: the person's prompts kept under the handoff, newest first, PERSON_MAX characters in all, each cut at
125// PERSON_EACH. 0.3.10: 48,000 (was 12,000): in a recall benchmark with chatty ~1,100-character messages, 12,000 kept
126// only the last ten or so, and an instruction older than that reached Claude only if its handoff repeated it
127export const PERSON_MAX = 48_000
128export const PERSON_EACH = 4_000
129// handoff_summary: the skills the conversation used, as they were loaded, newest first, SKILL_MAX characters in all,
130// each cut at SKILL_EACH (the engine's own compaction adds them back the same way)
131export const SKILL_MAX = 24_000
132export const SKILL_EACH = 8_000
133// $.session.compact's refusal where only a /compact run can compact (the desktop app, live 2026-10-13)
134export const isHeadlessRefusal = (err: unknown) => /not available in a headless/i.test(String(err))
135// Where a prompt comes from when a person sent it: typed at the terminal, through Remote Control, or as the SDK host's
136// turn (the desktop app's chats, `claude -p`). A plugin's prompt (catnap's own continue and ask) or a notification is
137// not the person asking.
138const PERSON_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk', 'unclassified'])
139// A prompt that asks for a compaction: /catnap:compact, a typed /compact line, "compact now", "take a nap" ("catnap"
140// alone is not one)
141export const asksToCompact = (text: string) => /\bcompact|\bnap\b/i.test(text)
142
143type Config = {
144 soft: number
145 threshold: number
146 auto: number
147 softTokens: number
148 thresholdTokens: number
149 autoTokens: number
150 off: boolean
151 fresh: boolean
152 handoffSummary: boolean
153}
154const DEFAULTS: Config = {
155 soft: DEFAULT_SOFT,
156 threshold: DEFAULT_THRESHOLD,
157 auto: DEFAULT_AUTO,
158 softTokens: DEFAULT_SOFT_TOKENS,
159 thresholdTokens: DEFAULT_THRESHOLD_TOKENS,
160 autoTokens: DEFAULT_AUTO_TOKENS,
161 off: false,
162 fresh: false,
163 handoffSummary: true,
164}
165// handoff: the file it is saved in ('' when there is none); note: the handoff text Claude passed itself ('' when it
166// passed a path only)
167type Job = { handoff: string; note: string; keep: string; percent: number }
168
169export const stateDirFor = (stateEnv: string | undefined, home: string | undefined): string => {
170 const base = stateEnv || (home ? `${home}/.claude/state` : '')
171 return base ? `${base.split('\\').join('/').replace(/\/+$/, '')}/${NAME}` : ''
172}
173
174// A line in percent: above 0 and below 100 (lowest = 0 for the auto line, where 0 means "leave it alone").
175const percentOption = (v: unknown, fallback: number, lowest = 1): number => {
176 const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
177 return Number.isFinite(n) && n >= lowest && n < 100 ? n : fallback
178}
179
180// A line in tokens: 0 (none) or more.
181const tokenOption = (v: unknown, fallback: number): number => {
182 const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
183 return Number.isFinite(n) && n >= 0 ? n : fallback
184}
185
186// The userConfig options as the base config, and the save step (empty: a plain "write a handoff file").
187export const fromOptions = (options: PluginOptions | undefined): { base: Config; save: string } => ({
188 base: {
189 soft: percentOption(options?.soft_percent, DEFAULT_SOFT),
190 threshold: percentOption(options?.strict_percent, DEFAULT_THRESHOLD),
191 auto: percentOption(options?.auto_percent, DEFAULT_AUTO, 0),
192 softTokens: tokenOption(options?.soft_tokens, DEFAULT_SOFT_TOKENS),
193 thresholdTokens: tokenOption(options?.strict_tokens, DEFAULT_THRESHOLD_TOKENS),
194 autoTokens: tokenOption(options?.auto_tokens, DEFAULT_AUTO_TOKENS),
195 off: false,
196 fresh: options?.fresh_start === true,
197 handoffSummary: options?.handoff_summary !== false,
198 },
199 save: typeof options?.save_step === 'string' ? options.save_step.trim() : '',
200})
201
202export const parseConfig = (texts: readonly (string | null)[], base?: Config): Config => {
203 const c: Config = { ...(base ?? DEFAULTS) }
204 // a token line: 0 (none) or more
205 const tokens = (v: unknown, now: number): number => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : now)
206 for (const text of texts) {
207 if (!text) continue
208 try {
209 const j = JSON.parse(text) as Partial<Record<keyof Config, unknown>> | null
210 if (typeof j?.soft === 'number' && j.soft > 0 && j.soft < 100) c.soft = j.soft
211 if (typeof j?.threshold === 'number' && j.threshold > 0 && j.threshold < 100) c.threshold = j.threshold
212 if (typeof j?.auto === 'number' && j.auto >= 0 && j.auto < 100) c.auto = j.auto
213 c.softTokens = tokens(j?.softTokens, c.softTokens)
214 c.thresholdTokens = tokens(j?.thresholdTokens, c.thresholdTokens)
215 c.autoTokens = tokens(j?.autoTokens, c.autoTokens)
216 if (typeof j?.off === 'boolean') c.off = j.off
217 if (typeof j?.fresh === 'boolean') c.fresh = j.fresh
218 if (typeof j?.handoffSummary === 'boolean') c.handoffSummary = j.handoffSummary
219 } catch {
220 // a broken config file is ignored, the defaults stand
221 }
222 }
223 return c
224}
225
226// The lines on this window: each percent line, or its token line in whole percent (rounded down, at least 1) when
227// that comes first. A token line of 0 or no window size leaves the line as set; an auto line of 0 ("leave it alone")
228// stays 0, the lower of the two.
229export const capped = (c: Config, window: number | undefined): Config => {
230 if (!window || !Number.isFinite(window) || window <= 0) return c
231 const cap = (percent: number, tokens: number) =>
232 tokens > 0 ? Math.min(percent, Math.max(1, Math.floor((tokens * 100) / window))) : percent
233 return {
234 ...c,
235 soft: cap(c.soft, c.softTokens),
236 threshold: cap(c.threshold, c.thresholdTokens),
237 auto: cap(c.auto, c.autoTokens),
238 }
239}
240
241// What CLAUDE_AUTOCOMPACT_PCT_OVERRIDE should hold under this config: the auto line, or, when the mod is off or the
242// line is 0, what the process had before the mod first set it ('' = it was unset).
243export const autoTarget = (c: Config, orig: string): { value: string | undefined; restore: boolean } =>
244 c.off || c.auto === 0 ? { value: orig === '' ? undefined : orig, restore: true } : { value: String(c.auto), restore: false }
245
246// How to save, in the notes: the save_step command when one is set (it writes a handoff file), else the handoff goes
247// straight into compact_now's `note`.
248// 0.3.10: the person's standing instructions too, since their oldest prompts can fall past PERSON_MAX
249const HANDOFF_PARTS =
250 'the task, what is done, the next step, open decisions, the files that matter, and every instruction from the person that still applies, in their words'
251const saveHow = (save: string): string =>
252 save
253 ? `run ${save} (it writes the handoff file and anything else not yet saved)`
254 : `write your handoff (${HANDOFF_PARTS}) straight into the note of the compaction call below; no separate file is needed`
255
256const saveShort = (save: string): string => (save ? `run ${save}` : 'write your handoff')
257
258// What compact_now gets: the handoff file's path when a save step wrote one, else the handoff itself as `note`.
259const callWith = (save: string): string =>
260 save ? "that handoff file's path as handoff" : 'your handoff as note'
261
262// compact_now in the same response as the last action (see the header); `what` names that action
263const alongside = (what: string): string =>
264 `Make that call in the same response as ${what} (for example the command that runs the tests), as its last tool call, ` +
265 'not in a reply of its own: a reply of its own reads the whole conversation once more. ' +
266 "You will not see that action's result before the compaction, so your handoff's next step starts with checking it."
267
268const steps = (tool: string, save: string): string =>
269 [
270 '1. Finish only the step you are on; start nothing new.',
271 `2. Save everything a fresh start would need: ${saveHow(save)}.`,
272 `3. Call ${tool} with ${callWith(save)}, and what the summary must keep (the task, the next step, open decisions) as keep. ` +
273 `${alongside("the step's last action")} If the step is already done, call it now.`,
274 '4. End your reply right there: no more tool calls and no closing summary. The compaction runs once your turn ends, and a message then gives you your handoff back and tells you to continue.',
275 ].join('\n')
276
277export const softText = (p: number, t: number, tool: string, save = '') =>
278 [
279 `[${NAME}] Context is at ${p}% of the window. Compact at a comfortable moment before ${t}%, where you will be told to compact at once:`,
280 '- Finish the piece you are on, and finish it properly (for example the page, section or feature: done, checked, saved). Do not start the next big piece before compacting.',
281 `- At that clean break: ${saveShort(save)}, call ${tool} with ${callWith(save)} and what the summary must keep, then end your reply.`,
282 `- ${alongside("the piece's last action")}`,
283 '- If your reply is ending anyway (the work is done, or you are waiting on the user), that is a good moment too.',
284 ].join('\n')
285
286export const noteText = (p: number, t: number, tool: string, save = '') =>
287 `[${NAME}] Context is at ${p}% of the window (the line is ${t}%). Save and compact now; do not wait for a better moment. In this order:\n${steps(tool, save)}`
288
289export const askText = (p: number, t: number, tool: string, save = '') =>
290 `[${NAME}] Your last turn ended with context at ${p}% of the window (the line is ${t}%). Compact before anything else, in this order:\n${steps(tool, save)}`
291
292// compact_now's answer below the line, when the person has not asked for a compaction
293export const earlyText = (p: number, line: number, tool: string) =>
294 `Not yet: context is at ${p}% of the window, below catnap's line (${line}%). Carry on with the work; a [${NAME}] note ` +
295 `will tell you when to compact. Don't call ${tool} again before then, unless the person asks for a compaction.`
296
297const QUEUED_NOTE =
298 `[${NAME}] Compaction is queued. End your reply now: no more tool calls and no more text. It runs as soon as the turn ends, and a message then tells you to continue.`
299
300// The reply catnap gives in Claude's place to the one model request after compact_now (see the header).
301export const NAP_REPLY = `[${NAME}] Compacting now; Claude carries on right after.`
302
303// 0.3.10: the reply line. A nap that rode along with Claude's last action came before Claude's reply on that step; in a
304// recall benchmark Claude then gave a status line instead of the reply the person had asked for, and believed it had
305// sent it (3 of 48 steps; 0 of 40 with auto-compact)
306const CARRY_ON =
307 'Carry on exactly where you left off, with the next step it names. ' +
308 'If you napped in the same response as your last action, the person has not had your reply on that step yet: once you have checked its result, give them that reply as you would have. ' +
309 'If the work was finished, or you were waiting on the user, say in a line or two where things stand and stop; do not start new work.'
310
311// The continue prompt. `text` is the handoff to give back (the note, or the file read back); null when the file could
312// not be read, and then the prompt names the file, as before 0.3.6. `cut`: the text was cut at PASTE_MAX.
313export const continueText = (job: Job, text: string | null, cut = false, fresh = false) => {
314 const head = fresh
315 ? `[${NAME}] This is a fresh start: the conversation was cleared at your request (context had reached ${job.percent}%), so you have only your handoff.`
316 : `[${NAME}] The conversation was just compacted at your request (context had reached ${job.percent}%).`
317 if (text === null) return `${head} Your handoff: ${job.handoff}. Read it first, then ${CARRY_ON.charAt(0).toLowerCase()}${CARRY_ON.slice(1)}`
318 const saved = job.handoff ? ` (saved in ${job.handoff})` : ''
319 const rest = cut ? `\n[cut here at ${PASTE_MAX} characters: read the rest in ${job.handoff} before you go on]` : ''
320 return `${head} Here is the handoff you wrote just before it${saved}:\n\n<handoff>\n${text}${rest}\n</handoff>\n\n${CARRY_ON}`
321}
322
323// handoff_summary: the person's prompts to keep, oldest first: the newest ones, each cut at PERSON_EACH, up to
324// PERSON_MAX characters in all (the newest is kept whatever its length)
325export const keepSaid = (all: readonly string[]): string[] => {
326 const kept: string[] = []
327 let total = 0
328 for (let i = all.length - 1; i >= 0; i--) {
329 const one = all[i] ?? ''
330 const t = one.length > PERSON_EACH ? `${one.slice(0, PERSON_EACH)} [cut]` : one
331 if (kept.length > 0 && total + t.length > PERSON_MAX) break
332 kept.unshift(t)
333 total += t.length
334 }
335 return kept
336}
337
338// handoff_summary: the skills the main conversation used, by name: a typed /name (the engine's <command-name> row),
339// a Skill tool call, or a <skill> block of catnap's summary from an earlier nap. A skill only a subagent loaded is
340// not among them.
341export const skillsIn = (messages: readonly { text: string; toolUses: readonly { tool: string; input: Record<string, unknown> }[] }[]) => {
342 const names = new Set<string>()
343 for (const m of messages) {
344 for (const hit of m.text.matchAll(/<command-name>\/([^<\s]+)<\/command-name>|<skill name="([^"]+)">/g)) names.add(hit[1] ?? hit[2] ?? '')
345 for (const u of m.toolUses) if (u.tool === 'Skill' && typeof u.input.skill === 'string') names.add(u.input.skill)
346 }
347 return names
348}
349
350// handoff_summary: of the skills loaded this session (name -> text, oldest first), the ones the conversation used,
351// oldest first: the newest ones, each cut at SKILL_EACH, up to SKILL_MAX characters in all
352export const keepSkills = (loaded: ReadonlyMap<string, string>, used: ReadonlySet<string>): [string, string][] => {
353 const kept: [string, string][] = []
354 let total = 0
355 for (const [name, text] of [...loaded].filter(([n]) => used.has(n)).reverse()) {
356 const t = text.length > SKILL_EACH ? `${text.slice(0, SKILL_EACH)}\n[cut here: load the skill again for the rest]` : text
357 if (kept.length > 0 && total + t.length > SKILL_MAX) break
358 kept.unshift([name, t])
359 total += t.length
360 }
361 return kept
362}
363
364// handoff_summary: what the conversation holds after catnap's own compaction, in place of the engine's summary
365export const ownSummaryText = (
366 job: Job,
367 text: string,
368 cut: boolean,
369 said: readonly string[],
370 skills: readonly [string, string][] = [],
371) => {
372 const saved = job.handoff ? ` (saved in ${job.handoff})` : ''
373 const rest = cut ? `\n[cut here at ${PASTE_MAX} characters: read the rest in ${job.handoff} before you go on]` : ''
374 const words = said.length
375 ? `\n\nThe person's messages in this session, word for word (the latest last):\n\n${said.map(t => `<message>\n${t}\n</message>`).join('\n')}`
376 : ''
377 const loaded = skills.length
378 ? '\n\nSkills used earlier in this session, as they were loaded: keep following their standing rules where they still apply, ' +
379 `and do not redo their one-time steps.\n\n${skills.map(([n, t]) => `<skill name="${n}">\n${t}\n</skill>`).join('\n')}`
380 : ''
381 return (
382 `[${NAME}] This conversation was compacted at your request (context had reached ${job.percent}%). ` +
383 `In place of a summary, here is the handoff you wrote just before it${saved}:\n\n<handoff>\n${text}${rest}\n</handoff>${words}${loaded}`
384 )
385}
386
387// handoff_summary: the one message the engine gets in place of the conversation, so the person's PreCompact hooks run
388// on catnap's compaction (see the header); never summarized, and dropped if an engine does
389const STAND_IN = '[catnap] This compaction is answered with the handoff; this message stands in for the conversation.'
390
391// handoff_summary: the continue prompt, when the handoff already stands in the summary's place
392export const ownContinueText = (job: Job) =>
393 `[${NAME}] The conversation was just compacted at your request (context had reached ${job.percent}%). Your handoff is above, in place of a summary. ${CARRY_ON}`
394
395const instructionsFor = (job: Job) =>
396 `Keep what the next turn needs to continue: ${job.keep}.` +
397 (job.handoff ? ` The full state is saved in ${job.handoff}; name that path in the summary.` : '') +
398 ' Claude gets its own handoff back, word for word, right after this compaction.'
399
400// The handoff to give back after the compaction: the note, else the file, cut at PASTE_MAX; null when there is no
401// note and the file can't be read. A note is cut only when a file holds the whole of it.
402// compact_now: a handoff field that holds the handoff itself, not a path (a model put its text there; live, Haiku 5.5
403// in 2 of 6 naps, 2026-10-22): more than one line, over 400 characters, six words or more, or no file extension at the
404// end. Only used when the field cannot be read as a file.
405export const proseHandoff = (s: string) =>
406 /[\r\n]/.test(s) || s.length > 400 || s.split(/\s+/).length >= 6 || !/\.[A-Za-z0-9]{1,10}$/.test(s)
407
408export const handoffText = async (job: Job, read: (path: string) => Promise<string | null | undefined>) => {
409 let text = job.note
410 if (!text) {
411 if (!job.handoff) return { text: null, cut: false }
412 const got = await read(job.handoff).catch(() => null)
413 if (typeof got !== 'string' || got.trim() === '') return { text: null, cut: false }
414 text = got.trim()
415 }
416 if (text.length <= PASTE_MAX || !job.handoff) return { text, cut: false }
417 return { text: text.slice(0, PASTE_MAX), cut: true }
418}
419
420export const register: Register = (on, options) => {
421 const { base, save } = fromOptions(options)
422 let toolName = toolNameFor(NAME) // replaced at session.start by the name the engine gives the plugin
423 let stateDir = ''
424 let sid = ''
425 let desktopChat = false // a desktop app chat, not a process started from inside one: acts with no window attached
426 let pending: Job | null = null // compact_now was called; runs when the turn ends
427 let turnNow = '' // the main loop's running turn, from turn.start
428 let napTurn = '' // the turn that called compact_now: the mod answers its next model request (see the header)
429 let due = false // the turn ended: the timer may compact now
430 let ask: string | null = null // a turn-end ask the timer submits
431 let softNoted = false // the soft note went out this cycle
432 let lastNote = -1 // percent at the last mid-turn note
433 let lastAsk = -1 // percent at the last turn-end ask
434 let lastPct = -1 // percent at the last check, for the drop fallback (-1: none since the cycle started)
435 let floor = -1 // percent at the first check after the last compaction (-1: none seen yet)
436 let floorNext = false // a compaction landed: the next check with a number sets the floor
437 let floorNoted = false // this floor's line move is logged
438 let floorWarned = false // the toast about a floor near the lines went out (once a session)
439 let busy = false
440 let retries = 0
441 let flagDirty = false // the pending flag file needs rewriting
442 let useSlash = false // this host refuses $.session.compact: compact with a /compact run
443 let slashJob: Job | null = null // a /compact run went out for this job and has not landed yet
444 let slashAt = 0
445 let landedJob: Job | null = null // its compaction landed: the timer sends the continue prompt
446 let clearJob: Job | null = null // fresh start: a /clear went out for this job and has not landed yet
447 let clearAt = 0
448 let landedFresh = false // the landed job was a fresh start: the continue prompt says so
449 let landedOwn = false // the session.compact hook answered with the handoff (handoff_summary): a short continue prompt
450 let said: string[] = [] // the person's prompts this session, for handoff_summary (keepSaid)
451 const skillTexts = new Map<string, string>() // each skill's text as last loaded this session, oldest first (keepSkills)
452 let gaveUp = false // compacting failed this cycle: no notes or asks until a compaction lands some other way
453 let personAsked = false // the person's latest prompt asks for a compaction: compact_now is not held to the line
454 let toast: string | null = null // a toast the timer shows (the host puts "catnap:" in front)
455 let autoOrig = '' // CLAUDE_AUTOCOMPACT_PCT_OVERRIDE before the mod first set it ('' = unset)
456 let autoNow: string | undefined // what the variable holds now
457 let autoTouched = false // the mod (this module or one before a reload) has set the variable
458 let autoWant: { value: string | undefined; restore: boolean } | null = null // what the timer sets it to
459 let autoWarned = false
460 let autoBroken = false // the host refused a write to the variable: stop trying
461 let timer: Timer | undefined
462 const events: Record<string, unknown>[] = []
463 let logDirty = false
464 let logWorthy = false // a person's session, or one that did something: only then does its log go to disk
465
466 const log = (kind: string, data: Record<string, unknown> = {}) => {
467 events.push({ at: new Date().toISOString(), kind, ...data })
468 if (events.length > 60) events.splice(0, events.length - 60)
469 if (kind !== 'start') logWorthy = true
470 logDirty = true
471 }
472 const configPaths = () => [`${stateDir}/config.json`, `${stateDir}/${sid}.config.json`]
473 const flagBody = () =>
474 JSON.stringify({ pending: pending !== null, at: new Date().toISOString(), handoff: pending?.handoff ?? null })
475 const reset = () => {
476 if (pending) flagDirty = true
477 pending = null
478 napTurn = ''
479 due = false
480 ask = null
481 softNoted = false
482 lastNote = -1
483 lastAsk = -1
484 lastPct = -1 // the mod's own compaction, or one the hook reported, is never seen again as a drop
485 retries = 0
486 slashJob = null
487 clearJob = null
488 gaveUp = false
489 personAsked = false // a compaction landed (or the cycle started over): the ask is done
490 }
491 const giveUp = (reason: string) => {
492 log('gave-up', { reason })
493 toast = `could not compact this chat (${reason}). The automatic compaction still applies.`
494 reset()
495 gaveUp = true
496 }
497 // The auto line under this config, for the timer to set; one toast when it sits at or below the strict line.
498 const wantAuto = (c: Config) => {
499 autoWant = autoTarget(c, autoOrig)
500 if (autoWant.restore || autoWarned || c.auto > c.threshold) return
501 autoWarned = true
502 log('auto-below-strict', { auto: c.auto, threshold: c.threshold })
503 toast = `auto-compact (${c.auto}%) is at or below the strict line (${c.threshold}%), so it runs before Claude is asked`
504 }
505 // Each check's percent, for the fallback: a fall of DROP_POINTS or more since the last check, with nothing of the
506 // mod's own queued or running, was a compaction the hook did not report. Logged once: the next check compares anew.
507 const sawPercent = (pct: number | undefined) => {
508 if (pct === undefined) return // none until the first response after a compaction: the next check compares
509 if (floorNext) setFloor(pct)
510 const before = lastPct
511 lastPct = pct
512 if (pending || slashJob || before < 0 || pct > before - DROP_POINTS) return
513 reset()
514 lastPct = pct
515 setFloor(pct)
516 log('compacted-elsewhere', { via: 'drop', from: before, to: pct })
517 }
518 const setFloor = (pct: number) => {
519 floor = pct
520 floorNext = false
521 floorNoted = false
522 }
523 // The lines this cycle: as set, but at least FLOOR_GAIN points above the floor (a soft line lifted to the strict
524 // one is off, as when set that way).
525 const lifted = (c: Config): Config =>
526 floor < 0 ? c : { ...c, soft: Math.max(c.soft, floor + FLOOR_GAIN), threshold: Math.max(c.threshold, floor + FLOOR_GAIN) }
527 const noteFloor = (c: Config, l: Config) => {
528 if (floorNoted || (l.soft === c.soft && l.threshold === c.threshold)) return
529 floorNoted = true
530 const next = l.soft < l.threshold ? l.soft : l.threshold
531 log('high-floor', { floor, soft: l.soft, threshold: l.threshold })
532 if (floorWarned) return
533 floorWarned = true
534 toast = `context was still at ${floor}% right after compacting (this session's fixed part, such as the system prompt, tools and CLAUDE.md, takes that much), so the next note waits for ${next}%`
535 }
536
537 on('session.start', async ($, e, next) => {
538 toolName = toolNameFor($.plugin.name)
539 // CATNAP_OFF (or its old name COMPACT_WHEN_READY_OFF), or the author's wrapper markers (each name spelled out: the
540 // engine lists what a mod reads)
541 const isWrapperChild =
542 (await $.env.get('CATNAP_OFF')) !== undefined ||
543 (await $.env.get('COMPACT_WHEN_READY_OFF')) !== undefined ||
544 (await $.env.get('CLAUDE_DELEGATE_LEG')) !== undefined ||
545 (await $.env.get('CLAUDE_HARNESS_PROBE')) !== undefined
546 // an empty state folder switches every check off: the tool still answers, nothing is ever injected or asked
547 stateDir = isWrapperChild
548 ? ''
549 : stateDirFor(await $.env.get('CLAUDE_STATE_DIR'), (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')))
550 sid = await $.session.id()
551 desktopChat =
552 (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) === 'claude-desktop' &&
553 (await $.env.get('CLAUDECODE')) === undefined &&
554 (await $.env.get('CLAUDE_CODE_CHILD_SESSION')) === undefined
555 // the auto line's starting point; after a reload the marker still holds the value from before the mod's first set
556 autoNow = await $.env.get('CLAUDE_AUTOCOMPACT_PCT_OVERRIDE')
557 const marked = await $.env.get('CATNAP_AUTO_ORIG')
558 autoTouched = marked !== undefined
559 autoOrig = marked ?? autoNow ?? ''
560 if (stateDir) {
561 // a reload starts the module over: keep this session's log, and drop a queued flag the old module left behind
562 try {
563 const old = JSON.parse((await $.fs.read(`${stateDir}/${sid}.log.json`)) ?? '[]') as unknown
564 if (Array.isArray(old) && events.length === 0) {
565 events.push(...(old as Record<string, unknown>[]).slice(-59))
566 logWorthy = true
567 }
568 } catch {
569 // no log yet
570 }
571 try {
572 const flag = JSON.parse((await $.fs.read(`${stateDir}/${sid}.json`)) ?? '{}') as { pending?: unknown } | null
573 if (flag?.pending === true) flagDirty = true
574 } catch {
575 // no flag yet
576 }
577 }
578 await $.tool.register({
579 name: TOOL,
580 description:
581 'Compacts this conversation once your turn ends, then gives you your handoff back and tells you to continue. ' +
582 `Use it when a [${NAME}] note asks you to, or when the person asks for a compaction; before that, carry on: ` +
583 'the window has room, and the note tells you when. ' +
584 (save
585 ? `Call it once everything needed to pick the work up again is saved (normally by ${save}), with the handoff file's path. `
586 : `Call it with your handoff as note (${HANDOFF_PARTS}); no separate file is needed. `) +
587 `${alongside('your last action')} ` +
588 'After calling it, end your reply at once: no more tool calls and no more text. Main conversation only.',
589 inputSchema: {
590 type: 'object',
591 properties: {
592 note: {
593 type: 'string',
594 description: `Your handoff, written for yourself right after the compaction: ${HANDOFF_PARTS}. catnap saves it and gives it back to you word for word.`,
595 },
596 handoff: {
597 type: 'string',
598 description: 'Path of a handoff file you saved (for example by a save command), if you saved one. catnap gives its text back to you after the compaction.',
599 },
600 keep: { type: 'string', description: 'What the compaction summary must keep: the task, the next step, open decisions.' },
601 },
602 required: ['keep'],
603 },
604 isDeferred: false,
605 })
606 const surfaces = [...(await $.session.surfaces())]
607 if (surfaces.length > 0 || desktopChat) logWorthy = true
608 log('start', { surfaces, desktopChat, root: $.plugin.root, stateDir, isWrapperChild, autoNow: autoNow ?? null })
609 if (stateDir && (desktopChat || surfaces.length > 0)) {
610 // no window size yet: the percent line stands until the first check that has one
611 const window = await $.session.usage().then(
612 u => u.context.window,
613 () => undefined,
614 )
615 wantAuto(capped(parseConfig(await Promise.all(configPaths().map(p => $.fs.read(p).catch(() => null))), base), window))
616 }
617
618 const tick = async () => {
619 if (busy) return
620 busy = true
621 try {
622 if (stateDir && flagDirty) {
623 flagDirty = false
624 await $.fs.write(`${stateDir}/${sid}.json`, flagBody())
625 }
626 const want = autoBroken ? null : autoWant
627 if (want && want.value !== autoNow && (autoTouched || !want.restore)) {
628 try {
629 // the first set keeps what was there, so off or 0 can put it back, also after a reload; since 2.1.302 a
630 // write the engine holds (a session under an organization's settings) resolves { deny } instead of rejecting
631 const kept = autoTouched ? undefined : await $.env.set('CATNAP_AUTO_ORIG', autoOrig)
632 if (kept?.deny) throw new Error(kept.deny)
633 const res = await $.env.set('CLAUDE_AUTOCOMPACT_PCT_OVERRIDE', want.value)
634 if (res?.deny) throw new Error(res.deny)
635 autoNow = want.value
636 autoTouched = true
637 log('auto-line', { value: want.value ?? null, restore: want.restore })
638 } catch (err) {
639 // a refused write must not hold up the compaction below: the auto line is dropped for this module
640 autoBroken = true
641 log('auto-line-refused', { error: String(err).slice(0, 200) })
642 }
643 }
644 if (ask !== null) {
645 const text = ask
646 ask = null
647 log('ask-submitted')
648 void $.prompt.submit({ text })
649 }
650 if (slashJob && (await $.clock.now()) - slashAt > SLASH_TIMEOUT_MS) giveUp('/compact did not compact within 10 minutes')
651 if (clearJob && (await $.clock.now()) - clearAt > SLASH_TIMEOUT_MS) giveUp('/clear did not run within 10 minutes')
652 const now = due && pending && !slashJob && !clearJob
653 ? parseConfig(await Promise.all(configPaths().map(p => $.fs.read(p).catch(() => null))), base)
654 : null
655 if (now?.fresh && pending) {
656 // fresh start: a /clear instead of a compaction; session.end (reason clear), or else the command's end, marks it
657 // landed, and the continue prompt then opens the new conversation with the handoff
658 const job = pending
659 clearJob = job
660 clearAt = await $.clock.now()
661 due = false
662 log('clear-submitted')
663 void $.command.run({ command: 'clear' }).then(
664 () => {
665 if (clearJob !== job) return
666 reset()
667 landedJob = job
668 landedFresh = true
669 floorNext = true
670 log('cleared', { via: 'command-result' })
671 },
672 err => {
673 if (clearJob === job) giveUp(`/clear was refused: ${String(err).slice(0, 160)}`)
674 },
675 )
676 }
677 if (due && pending && !slashJob && !clearJob) {
678 const job = pending
679 // handoff_summary always runs /compact: the session.compact hook below sees that one (live, the desktop app)
680 // and answers it, while catnap's own $.session.compact skips catnap's hooks (the test kit)
681 let viaSlash = useSlash || now?.handoffSummary === true
682 if (!viaSlash) {
683 try {
684 const res = await $.session.compact({ instructions: instructionsFor(job) })
685 reset()
686 if ('skip' in res && res.skip !== undefined) {
687 log('skipped', { reason: res.skip })
688 toast = `the compaction was skipped (${res.skip})`
689 } else {
690 log('compacted', { tokensBefore: res.tokensBefore, tokensAfter: res.tokensAfter })
691 landedJob = job
692 floorNext = true
693 }
694 } catch (err) {
695 // refused: this host never compacts directly (learned for the session), or a turn runs; a /compact
696 // prompt waits for the session to be idle, so it covers both
697 retries += 1
698 const headless = isHeadlessRefusal(err)
699 if (headless || retries >= FALLBACK_AFTER) {
700 if (headless) useSlash = true
701 viaSlash = true
702 log('slash-fallback', { attempts: retries, error: String(err).slice(0, 200) })
703 } else {
704 log('retry', { attempt: retries, error: String(err).slice(0, 200) })
705 }
706 }
707 }
708 if (viaSlash) {
709 slashJob = job
710 slashAt = await $.clock.now()
711 due = false
712 log('slash-submitted')
713 // runs as if typed, once the session is idle; a plugin's prompt may not begin with / (host check)
714 void $.command.run({ command: 'compact', args: instructionsFor(job) }).then(
715 res => {
716 // normally the session.compact hook saw it land first; if not, the finished command stands for it
717 if (slashJob !== job) return
718 reset()
719 landedJob = job
720 floorNext = true
721 log('compacted', { via: 'command-result', text: String(res.text ?? '').slice(0, 200) })
722 },
723 err => {
724 if (slashJob === job) giveUp(`/compact was refused: ${String(err).slice(0, 160)}`)
725 },
726 )
727 }
728 }
729 if (landedJob) {
730 const job = landedJob
731 landedJob = null
732 const freshStart = landedFresh
733 landedFresh = false
734 const own = landedOwn
735 landedOwn = false
736 sid = await $.session.id() // a fresh start runs under a new session id
737 if (own) {
738 // the handoff already stands in the summary's place
739 void $.prompt.submit({ text: ownContinueText(job) })
740 log('continue-submitted', { own: true })
741 } else {
742 const back = await handoffText(job, path => $.fs.read(path))
743 void $.prompt.submit({ text: continueText(job, back.text, back.cut, freshStart) })
744 log('continue-submitted', { handoffChars: back.text?.length ?? null, cut: back.cut, fresh: freshStart })
745 }
746 }
747 if (toast !== null) {
748 const text = toast
749 toast = null
750 $.ui.toast(text)
751 }
752 if (stateDir && flagDirty) {
753 flagDirty = false
754 await $.fs.write(`${stateDir}/${sid}.json`, flagBody())
755 }
756 if (stateDir && logDirty && logWorthy) {
757 logDirty = false
758 await $.fs.write(`${stateDir}/${sid}.log.json`, JSON.stringify(events, null, 1))
759 }
760 } catch {
761 // never let the timer die on a failed write
762 } finally {
763 busy = false
764 }
765 }
766 timer?.cancel() // a reload starts a fresh module, but a second session.start in one load must not double the timer
767 timer = $.clock.every(TICK_MS, () => void tick())
768 return next(e)
769 })
770
771 // Serves the mod's own compact_now, the documented way: a tool.call hook matched to the registered tool's full
772 // name (fixed text, so the directory's checks can see it answers only its own tool; a RegExp, because the generated
773 // types list only the tools known when they were written). It must read mcp__<NAME>__<TOOL>: see NAME above.
774 on('tool.call', { tool: /^mcp__catnap__compact_now$/ }, async ($, e) => {
775 if (e.agentId) return { deny: 'Only the main conversation can compact itself; a subagent cannot.' }
776 // below the line, only when the person asked (see the header); where the notes never come, never held, and a check
777 // that fails lets the call through
778 let early: { percent: number; line: number } | null = null
779 try {
780 if (!personAsked && stateDir && (desktopChat || (await $.session.surfaces()).length > 0)) {
781 const ctx = (await $.session.usage()).context
782 const pct = ctx.percent
783 const c = capped(parseConfig(await Promise.all(configPaths().map(p => $.fs.read(p).catch(() => null))), base), ctx.window)
784 const l = lifted(c)
785 const line = l.soft < l.threshold ? l.soft : l.threshold
786 if (!c.off && pct !== undefined && pct < line) early = { percent: pct, line }
787 }
788 } catch {
789 early = null
790 }
791 if (early) {
792 log('too-early', early)
793 return { deny: earlyText(early.percent, early.line, toolName) }
794 }
795 const input = e as unknown as { note?: unknown; handoff?: unknown; keep?: unknown }
796 let note = String(input.note ?? '').trim()
797 let handoff = String(input.handoff ?? '').trim()
798 const keep = String(input.keep ?? '').trim()
799 if (!note && !handoff)
800 return {
801 deny: save
802 ? `Save everything first (run ${save}), then call again with the handoff file's path as handoff.`
803 : `Write your handoff first and pass it as note (${HANDOFF_PARTS}), then call again.`,
804 }
805 if (!note && proseHandoff(handoff) && (await $.fs.read(handoff).catch(() => null)) == null) {
806 // the handoff itself, written into the path field: it goes as the note
807 note = handoff
808 handoff = ''
809 }
810 if (note && !handoff && stateDir) {
811 // a copy on disk: for a person to look at, and for the continue prompt to point at if the note is cut
812 const path = `${stateDir}/${sid}.handoff.md`
813 try {
814 await $.fs.write(path, `${note}\n`)
815 handoff = path
816 } catch {
817 // the note still comes back from memory
818 }
819 }
820 pending = {
821 handoff,
822 note,
823 keep: keep || 'the current task and its next step',
824 percent: (await $.session.usage()).context.percent ?? -1,
825 }
826 napTurn = turnNow
827 due = false
828 retries = 0
829 gaveUp = false // a call of the model's own is a fresh try
830 flagDirty = true
831 log('queued', { handoff, noteChars: note.length, percent: pending.percent })
832 return {
833 result:
834 'Compaction queued. End your reply now: write nothing more and call no more tools. ' +
835 'The conversation is compacted as soon as this turn ends, and a message then tells you to continue.',
836 }
837 }).catch(() => ({ deny: 'compact_now failed inside the mod. Your work is saved; run /compact yourself, or call it again.' }))
838
839 // A person's prompt says whether they ask for a compaction (compact_now is then not held to the line); other prompts
840 // (catnap's own, a notification, a scheduled one) leave that as it is. The person's prompts are also kept for
841 // handoff_summary, word for word.
842 on('prompt.submit', async ($, e, next) => {
843 if (PERSON_ORIGINS.has(e.origin?.kind ?? '')) {
844 personAsked = asksToCompact(e.text ?? '')
845 const text = (e.text ?? '').trim()
846 if (text) said = keepSaid([...said, text])
847 }
848 return next(e)
849 })
850
851 // Each skill's text as the engine loads it (a typed /name, the Skill tool, a preload), for handoff_summary; catnap's
852 // own commands are left out. Read only: the text goes on as it was.
853 on('skill.prompt', async ($, e, next) => {
854 const r = await next(e)
855 if (e.pluginName !== $.plugin.name && !e.skill.startsWith(`${$.plugin.name}:`)) {
856 skillTexts.delete(e.skill) // the newest load last
857 skillTexts.set(e.skill, r.text)
858 }
859 return r
860 }).catch(($, e, next) => next(e))
861
862 // The main loop's running turn, so a compact_now call knows which turn it belongs to.
863 on('turn.start', async ($, e, next) => {
864 if (e.agentId === undefined) turnNow = e.turnId
865 return next(e)
866 })
867
868 // The model request right after compact_now could only end the reply (its tool result says so), yet it re-reads the
869 // whole context: the mod answers that one request with NAP_REPLY instead, and the turn ends. Only in the turn that
870 // called compact_now (turn.complete clears it), in the main loop, once; every other request goes to the model, and a
871 // hook that fails here leaves the request to the model too.
872 on('turn.step', async function* ($, e, next) {
873 if (e.agentId !== undefined || !pending || !napTurn || e.turnId !== napTurn) return yield* next(e)
874 napTurn = ''
875 log('nap-reply', { step: e.index })
876 yield { kind: 'text', index: 0, text: NAP_REPLY }
877 yield { kind: 'stop', stopReason: 'end_turn', usage: null }
878 return { turnId: e.turnId, index: e.index, answer: NAP_REPLY, toolUses: [], stopReason: 'end_turn', usage: null }
879 })
880
881 // Every other tool call goes to its tool; past the lines, the result gets a note.
882 on('tool.call', async ($, e, next) => {
883 const r = await next(e)
884 if (e.tool === toolName || e.agentId || r.deny !== undefined) return r
885 if (pending) return { ...r, context: [...(r.context ?? []), QUEUED_NOTE] }
886 if (!stateDir || (!desktopChat && (await $.session.surfaces()).length === 0)) return r
887 const ctx = (await $.session.usage()).context
888 const pct = ctx.percent
889 sawPercent(pct) // before the give-up check: a compaction seen this way also ends a give-up
890 if (gaveUp) return r
891 const c = capped(parseConfig(await Promise.all(configPaths().map(p => $.fs.read(p).catch(() => null))), base), ctx.window)
892 wantAuto(c)
893 if (c.off) return r
894 if (pct === undefined) return r
895 const l = lifted(c)
896 noteFloor(c, l)
897 if (pct < l.threshold) {
898 // the soft line: one note per cycle, then quiet until the strict line
899 if (softNoted || l.soft >= l.threshold || pct < l.soft) return r
900 softNoted = true
901 log('soft-note', { percent: pct, soft: l.soft, threshold: l.threshold })
902 return { ...r, context: [...(r.context ?? []), softText(pct, l.threshold, toolName, save)] }
903 }
904 if (lastNote >= 0 && pct < lastNote + REMIND_STEP) return r
905 softNoted = true // past the strict line the soft note is moot
906 lastNote = pct
907 log('note', { percent: pct, threshold: l.threshold })
908 return { ...r, context: [...(r.context ?? []), noteText(pct, l.threshold, toolName, save)] }
909 }).catch(($, e, next) => next(e)) // fail open: a broken note never blocks a tool
910
911 on('turn.complete', async ($, e, next) => {
912 const r = await next(e)
913 if (e.agentId) return r
914 sid = await $.session.id()
915 if (pending) {
916 napTurn = '' // the turn is over: no later request is answered in Claude's place
917 // interrupted or errored: stay queued, it runs at the next turn that ends normally
918 if (e.reason === 'answer') {
919 due = true
920 retries = 0
921 log('due')
922 }
923 return r
924 }
925 if (!stateDir || (!desktopChat && (await $.session.surfaces()).length === 0)) return r
926 const ctx = (await $.session.usage()).context
927 const pct = ctx.percent
928 sawPercent(pct) // an auto-compact inside the turn shows here at the latest
929 if (gaveUp || e.reason !== 'answer') return r
930 const c = capped(parseConfig(await Promise.all(configPaths().map(p => $.fs.read(p).catch(() => null))), base), ctx.window)
931 wantAuto(c)
932 if (c.off || pct === undefined) return r
933 const l = lifted(c)
934 noteFloor(c, l)
935 if (pct < l.threshold) return r
936 if (lastAsk >= 0 && pct < lastAsk + ASK_STEP) return r
937 lastAsk = pct
938 ask = askText(pct, l.threshold, toolName, save)
939 log('ask', { percent: pct, threshold: l.threshold })
940 return r
941 })
942
943 on('session.compact', async ($, e, next) => {
944 // handoff_summary: the /compact this mod ran is answered here, with the handoff in place of the engine's summary,
945 // so no model call summarizes the conversation (see the header); a handoff that can't be read goes to the engine
946 const ours = !e.agentId && slashJob && (e.trigger === 'manual' || e.trigger === 'plugin') ? slashJob : null
947 if (ours && parseConfig(await Promise.all(configPaths().map(p => $.fs.read(p).catch(() => null))), base).handoffSummary) {
948 const back = await handoffText(ours, path => $.fs.read(path))
949 if (back.text !== null) {
950 // the person's PreCompact hooks run beneath next(): hand it the stand-in, and stop if one of them blocks
951 let pre: SessionCompactResult | null = null
952 try {
953 pre = await next({ ...e, messages: [{ role: 'user' as const, text: STAND_IN, toolUses: [] }] })
954 } catch (err) {
955 log('pre-compact', { outcome: 'ran', note: String(err).slice(0, 160) }) // "Not enough messages to compact."
956 }
957 if (pre && 'skip' in pre && pre.skip !== undefined) {
958 const reason = String(pre.skip)
959 reset()
960 log('pre-compact', { outcome: 'blocked' })
961 log('skipped', { via: 'prompt', reason })
962 $.ui.toast(`the compaction was skipped (${reason})`)
963 return pre
964 }
965 if (pre) log('pre-compact', { outcome: 'summarized', tokensAfter: pre.tokensAfter }) // dropped: the handoff stands
966 const text = ownSummaryText(ours, back.text, back.cut, said, keepSkills(skillTexts, skillsIn(e.messages)))
967 reset()
968 landedJob = ours
969 landedOwn = true
970 floorNext = true
971 log('compacted', { via: 'prompt', trigger: e.trigger, own: true, chars: text.length })
972 return { messages: [{ role: 'user' as const, text, toolUses: [] }] }
973 }
974 }
975 const r = await next(e)
976 const landed = !('skip' in r && r.skip !== undefined)
977 if (!e.agentId && slashJob && (e.trigger === 'manual' || e.trigger === 'plugin')) {
978 // the /compact this mod ran: on landing the timer sends the continue prompt
979 const job = slashJob
980 if (landed) {
981 reset()
982 landedJob = job
983 floorNext = true
984 log('compacted', { via: 'prompt', trigger: e.trigger, tokensBefore: r.tokensBefore, tokensAfter: r.tokensAfter })
985 } else {
986 const reason = 'skip' in r && r.skip !== undefined ? String(r.skip) : 'skipped'
987 reset()
988 log('skipped', { via: 'prompt', reason })
989 $.ui.toast(`the compaction was skipped (${reason})`)
990 }
991 return r
992 }
993 if (!e.agentId && landed && (e.trigger === 'auto' || e.trigger === 'manual')) {
994 // the backstop or the user compacted: whatever was queued is done, start the note cycle over
995 reset()
996 floorNext = true
997 log('compacted-elsewhere', { trigger: e.trigger })
998 }
999 return r
1000 }).catch(($, e, next) => next(e)) // fail open: never block a compaction
1001
1002 on('session.end', async ($, e, next) => {
1003 const ours = clearJob !== null && e.reason === 'clear' ? clearJob : null
1004 reset()
1005 if (ours) {
1006 // the fresh start this mod ran: the timer opens the new conversation with the handoff
1007 landedJob = ours
1008 landedFresh = true
1009 log('cleared', { via: 'session-end' })
1010 }
1011 // a /clear ends the conversation but not this module (no session.start follows): the next one starts with no floor
1012 floor = -1
1013 floorNext = false
1014 floorNoted = false
1015 floorWarned = false
1016 return next(e)
1017 })
1018}
1019