SLOPSHOPPER

catnap

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…

guardtoastprompttooltimer
★ 7v0.3.12MITupdated 2026-10-22LippInc/catnap
A shopper browsing a rack in a slop shop
README

<img alt="catnap: a pixel cat asleep on its notes" src="launch/catnap.svg" width="220">

catnap

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.

Install

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.

Why

| | 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.

What happens

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.

  • Before 50% (300K on 1M): catnap stays quiet, and Claude doesn't compact on its own: if it calls 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.
  • At 50% context (300K on 1M): one note on a tool result. It tells Claude to finish the piece it is on properly (the page, the section, the feature), and to save and compact at that clean break.
  • At 60% (400K on 1M): a strict note telling Claude to save and compact now. It repeats every 2 points until Claude does. If a turn ends past that line without a compaction, catnap asks with a prompt of its own, again every 5 points.
  • 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.
  • At 70% (500K on 1M): Claude Code's own auto-compact, the backstop if Claude hasn't compacted by then. Without catnap it would wait until the window is nearly full.

All the lines are settings (below).

Settings

| 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.

  • Turn it off for one process: set CATNAP_OFF=1 (the old name COMPACT_WHEN_READY_OFF=1 still works). Auto-compact is then left as Claude Code has it.
  • Turn it off everywhere: disable the plugin in /plugin.
  • Uninstall: 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.
  • How the auto line works: the mod sets 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.
  • Limits of the auto line: Claude Code can only move auto-compact earlier than its own default, never later, so values near 100 do nothing. Like the notes, it is only set in a person's session (a terminal, or a desktop app chat): claude -p runs keep Claude Code's own setting.

Works with

| 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 |

Port it to your agent

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.

Numbers

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.

FAQ

  • Isn't this just auto-compact at 50%? Most of the saving comes from compacting earlier, and a lower auto-compact setting does that too. But auto-compact lands mid-task almost every time (78 of 82 in the author's logs). catnap waits until Claude has finished the piece it is on, so its compactions come between steps rather than in the middle of one, and after the compaction Claude carries on from the note it wrote, with no summary call.
  • Why not fast-jev-compaction? It changes what goes into the summary; catnap changes when the compaction happens. Using both together is untested.
  • Does it cost tokens? Each note costs a few hundred tokens. Claude passes its handoff in the same response as its last action, so a nap usually takes no model call of its own (10 of 11 naps in a 200K benchmark). Since 0.3.9 there is no summary call either: the handoff stands in place of Claude Code's summary. catnap answers the request after 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.
  • Can it compact at a bad moment? Claude picks the moment, between your lines. If you catch it compacting at a moment you think is wrong, an issue with the session's .log.json is the most useful thing you can send.
  • What if Claude Code does this itself one day? Then uninstall catnap: claude plugin uninstall catnap@lippinc. Until then it works today.

How it compacts

  • With 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.
  • With it off, in a terminal session the mod compacts directly, between turns. In the desktop app's Code tab a direct compaction is refused ("not available in a headless (-p / SDK) session"), so there the mod runs /compact with its instructions, as if you had typed it.
  • If /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.
  • A compaction the mod didn't run (auto-compact, or a /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.
  • When a compaction frees almost nothing, the notes wait. That happens when the part of a session that never compacts (the system prompt, tools, a large CLAUDE.md) is itself near the lines. So after any compaction, both lines sit at least 10 points above where it left the context, for that round, and a toast says so once. (Where the 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.
  • It stays out of scripted runs: 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.
  • A desktop app chat counts as yours even while no window shows it. The app attaches its window to a chat and detaches it again as it goes, so the mod knows the app's chats by Claude Code's entry point (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.

Files it writes

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.

What it can reach

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.

  • Hooks:
  • 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 afte
Source 1 files
hooks/register.ts 1019 lines
1import 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