SLOPSHOPPER

self-clear

For long, unattended agent runs: when the conversation reaches a size you set, Claude writes a progress note, the mod checks it and clears the conversation…

newcommandtoaststatusprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · self-clear
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /self-clear ⎿ self-clear: context 97K · threshold 500K · off (no .claude/self-clear.json) · auto-resume off · spawn gate off · checkpoin ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

self-clear

ภาษาไทย

For long Claude Code agent runs that nobody watches: when the conversation gets big, Claude writes a progress note, self-clear clears the conversation and Claude carries on, so steps stay smaller and cost less.

self-clear is a mod for Claude Code.

When the conversation gets big, self-clear asks Claude to write a short note about where the work is (a "checkpoint"). It checks that the note was really written, then clears the conversation with /clear. With autoResume on (the setup below turns it on), it then tells Claude to read the note and carry on. Each step after that is smaller, so it costs less.

Status: version 0.3.1. Tested on Windows only. Needs a model with a 1M-token context window. MIT license.

Words used below:

  • A turn is one reply from Claude, from your message until Claude stops and waits.
  • A 1M-token context window means the model can hold about 1 million tokens (pieces of words) of conversation.
  • A cycle is one round of: note, clear and (if switched on) carry on.
  • A subagent is a helper agent that Claude starts for part of the work.
  • A mod is a kind of Claude Code plugin, which is why you install it with /plugin.

This project is not affiliated with Anthropic and is not endorsed by Anthropic. "Claude" and "Claude Code" are trademarks of Anthropic, PBC.

Install (about 2 minutes)

You need Claude Code and a model with a 1M-token context window.

Step 1: add the mod. In Claude Code, type these two commands, one at a time:

/plugin marketplace add adithepmeekhun/self-clear
/plugin install self-clear@self-clear

If you prefer a terminal, these do the same thing:

claude plugin marketplace add adithepmeekhun/self-clear
claude plugin install self-clear@self-clear

Step 2: restart. Close Claude Code and open it again, or start a new session.

Step 3: turn it on for one project. The mod does nothing until a project asks for it. In the project folder, make a folder named .claude (with the dot), and inside it a file named self-clear.json that holds this:

{ "enabled": true, "autoResume": true }

You can copy and run one of these lines in the project folder. On macOS or Linux:

mkdir -p .claude && echo '{"enabled": true, "autoResume": true}' > .claude/self-clear.json

On Windows PowerShell:

New-Item -ItemType Directory -Force .claude | Out-Null; '{"enabled": true, "autoResume": true}' | Set-Content .claude/self-clear.json

You can also ask Claude to create .claude/self-clear.json with the text above, and approve the file write.

If your agent starts many subagents in one long turn, or runs overnight with nobody watching, use this instead. The "current settings" results below were measured with it:

{ "enabled": true, "autoResume": true, "gateSpawns": true }

See Spawn gate for what gateSpawns does. For runs nobody watches, allow file writes in the note folder in your Claude Code permission settings, or the run will stop at the first note and wait for you.

Step 4: check that it works. Type /self-clear. You should see on, threshold 500K and phase idle. The first time, you also get a notice that self-clear is on, with its settings (from then on it appears when Claude Code starts in this project).

  • If you see off (no .claude/self-clear.json), the file is missing or in the wrong folder. It must be inside the project you opened.
  • If you see off (.claude/self-clear.json is not valid JSON), check the quotes and commas in the file.
  • If you see off in this project, the file is there but does not say "enabled": true (write true without quotes).
  • If you see off in this session (the note folder leads outside the project), your checkpointDir is a link or points outside the project. Change it (or remove it to use the default) and restart Claude Code.
  • Any other off (…) message means the file is not a small, plain JSON file such as { "enabled": true }.

Nothing else happens until the conversation reaches the threshold (500K tokens). When a cycle runs, you will see two notices: self-clear: context …K, asking for a checkpoint, and later self-clear: cleared after checkpoint …, resuming.

To turn it off in one project, delete the file or set "enabled": false.

To remove it completely, run /plugin uninstall self-clear@self-clear and then /plugin marketplace remove self-clear. Old notes stay in the note folder (.tmp/self-clear unless you changed it); delete it by hand if you want.

Why use it

Agent work is long. An agent plans, builds, tests, reviews and fixes, often with subagents and often overnight. In Claude Code, every step sends the whole conversation to the model again. So the longer the agent works, the more each step costs. With a 1M-token context window, one step can send several hundred thousand tokens. This goes on until Claude Code squeezes the conversation by itself near the top of the window ("auto-compaction").

self-clear helps in four ways:

  • You don't have to sit and clear by hand. Without it, you watch the context meter, stop the agent, ask for a handoff note and type /clear yourself. With autoResume on, self-clear does all of that and the agent goes on. Long jobs and overnight runs can pass through many cycles.
  • Clears before auto-compaction does. Auto-compaction squeezes the conversation when the window is almost full. You don't choose when it happens or what it keeps. self-clear clears earlier, at the size you set, and what carries over is a note that Claude wrote and you can read. In the numbers below, auto-compactions went from 19 (in 3 long sessions) to 0.
  • Each step starts small again. After a clear, each step sends much less, so it costs less. In the numbers below, a step cost about 20% less, and that already includes the cost of writing the notes and rebuilding the cache. On the same model it was about 22%. Per day the saving was smaller, about 8%, because the team made more steps per day.
  • It is careful. It only clears when a new note carries a one-time code for this cycle. It does not judge what the note says. It backs off when you type, and it waits for work that is still running in the background.

Results so far

These numbers come from one team's real, long-running agent sessions on 1M-context models, with the author's pre-release build of this mod (0.3.1 adds the safety checks described below and uses its own built-in note request). They were read from local session logs with tools/measure_effect.py, which is included so you can measure your own.

If one step cost 100 before self-clear, it now costs about 80:

What a step pays forBefore (Sep 28 to Oct 6, 2026, no self-clear)Current settings (Oct 12 to 13, 2026)Change
Reading the saved conversation (cache read)80.554.2−26.3 (this item went down 33%)
Saving to the cache (cache write; every clear adds a full re-save)9.814.0+4.2 (this item went up 43%)
Claude's answer (output)9.712.0+2.3 (this item went up 24%)
Total10080.2−19.8 (a step costs 20% less)

How to read it: the old cost of one step is set to 100 and split into its three parts. The "Current settings" column uses the same scale. The % in brackets compares each item with its own old value.

  • What you gain: each step reads much less of the old conversation.
  • What you pay: after every clear, the conversation has to be saved to the cache again. That cost is counted in the table. The answers also got a little longer, but that is mostly because the model changed between the two periods. On the same model, answers did not get longer.
  • Overall: one step costs about 20% less.

"Cost" here means tokens counted by their API price compared with plain input: a cache read counts 0.1, a 1-hour cache write counts 2, and output counts 5. Plain input and 5-minute cache writes (counted 1 and 1.25) were near zero, so they are left out of the table. This is not the usage quota of your Claude plan. Anthropic does not publish how that quota is counted.

More numbers from the same logs:

Before (Sep 28 to Oct 6)With self-clear (Oct 7 to 13)Current settings (Oct 12 to 13)
Steps (API calls)9,5585,9781,999
Context per step: middle value / 90th percentile (9 in 10 steps were smaller than this)455K / 852K352K / 594K336K / 489K
Auto-compactions1900
Clears by self-clear03716
Cost per step (before = 100)1008380

Most clears came within 8 minutes after the conversation passed the threshold (counted by hand from the same logs).

Please read these numbers with care:

  • Different models. "Before" used Opus only. Oct 12 to 13 used Sonnet only. On the same model (Opus, Oct 10 to 11 at the 500K threshold), a step cost about 22% less than before, so the gain is not just the model change.
  • Per step, not per day. On comparable full days (Oct 10 to 12), the team made more steps per day than before (about 1,240 against 1,060), so the total cost per day went down less: about 8%. The amount of work changes from day to day, so we don't claim a total saving.
  • The Oct 7 to 13 column includes 3 days on an earlier, buggier version with a 600K threshold. "Current settings" means a 500K threshold with autoResume and gateSpawns on. Oct 13 is not a full day.
  • One team, one kind of work. "Before" and "after" are different days. Your numbers will be different, so measure your own.
  • It only helps sessions that grow past the threshold. Short sessions see no change.

How it works

waiting
 └ a turn ends with the conversation at or above the threshold, and nothing runs in the background
    └ ask Claude for a NEW note that must contain a one-time code
       └ the next turn ends: a new note holds the code,
         nothing runs in the background, and you did not type in between
          └ /clear, then (with autoResume) "read <note> and continue from its next step"

It never clears without a new note for this cycle.

Settings

All settings go in .claude/self-clear.json. Write real JSON values, like true and 500000, not text in quotes.

KeyDefaultWhat it does
enabledfalseMust be true, or the mod stays off in this project
thresholdTokens500000Start a cycle after a turn that ends at or above this size. Kept between 200,000 and 850,000
autoResumefalseAfter /clear, tell Claude to continue from the note. Use true when nobody is watching
gateSpawnsfalseRecommended for agents that start many subagents. See "Spawn gate" below
checkpointDir.tmp/self-clearFolder for the notes, inside the project, such as notes/handoffs. Each folder name uses letters, numbers, ., _ and -, and starts with a letter, number or _ (the built-in default .tmp/self-clear is the one exception). If the value is not allowed, the default is used instead
checkpointPrefixcheckpointNote file names start with this. Use letters, numbers, ., _ and -, and start with a letter or number. Otherwise the default is used

You can also set threshold_tokens and auto_resume once for all your projects in the plugin's options (claude plugin configure self-clear@self-clear shows them). The project file wins.

Tip: add .tmp/ to your .gitignore so the notes are not committed. Each cycle writes a new note file, so delete old ones whenever you like.

Spawn gate (for agent-heavy runs)

Some agents go a very long time without ending a turn. They start one subagent after another inside one long turn, so the conversation can grow far past the threshold before the turn ends. Set "gateSpawns": true to handle this:

  • At or above the threshold, the next subagent that the main conversation tries to start is refused. The refusal carries the request for a note. While the cycle runs, new subagents that Claude starts on its own are refused too. So the turn winds down and ends, and the clear happens at the end of that turn.
  • If you type a message during the cycle, your request goes first: the next subagent Claude starts for it is let through, and the cycle stops.
  • Subagents that are already running are never stopped. The clear waits for them.
  • It starts a cycle even while background work runs. The /clear itself still waits for that work.
  • If a cycle is dropped (no note after the asks), the next 3 subagents are let through, so your work never gets stuck.
  • Use it together with "autoResume": true, so a run that stops mid-turn picks up again after the clear.

Commands

  • /self-clear shows the conversation size, the threshold, the settings and the current phase.
  • /self-clear now starts a cycle right away.

Safety

  • Off by default. Nothing happens in a project until it has .claude/self-clear.json with "enabled": true. When Claude Code starts in a project whose file turns it on, a notice says so, with its settings.
  • You come first. If you type while a cycle runs, your message goes first. A note written before your message no longer counts: the clear is cancelled with a notice, and it asks again later. With the spawn gate on, the cycle also stops as soon as Claude starts a subagent for your message. Input that does not come from a person, such as a "background job finished" notice, a subagent's reply or a scheduled trigger, does not cancel. It only makes an older note out of date, so Claude is asked once more and the late result ends up in the note.
  • Waits for background work. If background shells, subagents or monitors are still running, it waits until they finish. A shell whose whole command is a sleep timer ([timeout N] sleep N [; echo word], a common wake-up trick) does not count as work. If real work keeps running while the conversation is more than 100K past the threshold, it asks for the note at the third turn end anyway. The /clear itself still waits until the work is done.
  • Code check. Before it clears, it checks that a new note holds this cycle's one-time code. If no note is written after two asks, it gives up and does not clear. A third ask happens only when a late notice makes the note out of date, so it never asks more than 3 times per cycle.
  • No endless asking. If two cycles in a row end without a usable note, it pauses until the conversation grows by another 100K tokens, or until it drops below the threshold again (for example after a clear or a compaction). Cycles you interrupt yourself do not count. /self-clear shows the pause, and /self-clear now starts a cycle anyway.
  • Labelled messages. Its messages say they come from the plugin, so Claude does not treat them as your words. A project cannot change these messages.
  • Projects you download. A project you clone can ship its own .claude/self-clear.json, and that turns the mod on there. So the project file can only switch it on or off, set the threshold, auto-resume and the spawn gate, and pick a note folder and file-name prefix inside the project. It cannot change what self-clear tells Claude. The project file itself must be a small, plain file (not a link).
  • The note folder stays inside the project. Before it asks for a note, the mod checks where the note folder really leads. If it leads outside the project (through a link, a junction or a different spelling), the mod stays off for the rest of the session and says so. If the check itself fails, it skips that ask without a notice and tries again at the next turn end. If /self-clear says on but no cycle ever starts past the threshold, this check is one possible cause.
  • Notes it reads. It asks Claude for a new note with an exact name, such as checkpoint-20261013-153000.md (date and time in UTC). It only reads new notes in that folder: names that start with the prefix (upper or lower case), use only letters, numbers, ., _ and -, and were not already in the folder before the cycle's first ask. It skips links, Windows device names (such as NUL) and files over 1 MB, and it checks at most the 5 newest notes. It has no network code and runs nothing on install.

Found a security problem? See SECURITY.md.

Limits

  • 1M context windows only. The threshold cannot go below 200K. Sessions with a 200K window (older models, some gateways, or CLAUDE_CODE_DISABLE_1M_CONTEXT=1) reach auto-compaction first.
  • Checked when a turn ends, or when a subagent starts if the spawn gate is on. One long turn can grow past the threshold before a cycle starts.
  • Background work that never ends blocks the clear. A dev server or file watcher started in the background counts as running work, so the /clear waits as long as it runs. /self-clear shows the phase. Stop that work to let the cycle finish.
  • Permission prompts. In the default permission mode, writing the note may ask for your approval. The cycle waits until you answer. For runs nobody watches, allow file writes in the note folder in your permission settings.
  • Timers it does not recognise. Only sleep timers in a shell are ignored. A PowerShell Start-Sleep timer, a monitor or a script that waits still counts as background work.
  • Rules that must survive a clear need to live in a file. After /clear, the model forgets what it learned earlier in the session. If your work has standing rules, keep them in CLAUDE.md or in a file the note tells Claude to read first.
  • New session after each clear. /clear starts a new session id, so the scratchpad folder changes. If the next step needs files from the scratchpad, keep them inside the project, or add a line to CLAUDE.md asking Claude to write full paths in its notes. The built-in request does not ask for full paths; it only gives Claude the note's own full path when it carries on.
  • Tested platforms. Tested on Windows with Claude Code 2.1.286 to 2.1.295 (desktop app and CLI). macOS and Linux are not tested yet. Reports are welcome.
  • Not on network shares. A project on a network path (such as \\server\share\project) cannot be checked, so cycles do not start there.
  • Experimental API. Mods are a new Claude Code feature (October 2026), and their API may change.

Measure it yourself

python tools/measure_effect.py --transcripts ~/.claude/projects/<project-folder> --switch YYYY-MM-DD --threshold 500000
  • <project-folder> is your project's folder under ~/.claude/projects/. The easiest way is to open that folder and pick the name that looks like your project's path: every /, \, : and space becomes - (for example C:\work\my app becomes C--work-my-app).
  • --switch is the day you turned self-clear on.
  • Add --after-from YYYY-MM-DD to measure only the days since you last changed settings, and --utc-offset for your time zone (for example --utc-offset 7 for UTC+7).

It reads only token counts from your local session logs. It prints the before and after table, the breakdown by token type, and the steps below and above the threshold inside the same sessions.

Development

cd plugins/self-clear
claude plugin test

The 89 tests cover the full cycle, the code check, refusals, cancelling when you type, waiting for background work, the settings and their defaults, the safety checks on the project file and the note folder, the pause after failed cycles, notices and replies that must not cancel, wake-up timers that must not block, inputs that arrive during the note-writing turn, and the spawn gate.

Run claude plugin validate . at the repo root before a release. Raise version in plugin.json for every change, because installs are cached per version. See CONTRIBUTING.md.

License

MIT, see LICENSE.

Source 1 files
hooks/register.js 686 lines
1// self-clear: at a context threshold, have Claude write a checkpoint, check it was written,
2// run /clear, then (optionally) resume from the checkpoint.
3// Installed globally (user scope) but OFF unless a project opts in with .claude/self-clear.json
4// {"enabled": true, ...}: clearing a conversation is never done where nobody asked for it.
5// Written against Claude Code v2.1.286 mods (tested up to 2.1.295).
6//
7// Cycle: idle → (turn ends ≥ threshold, no subagent running) ask for a checkpoint carrying a token
8//   → (a turn ends) newest checkpoint file changed since the ask AND contains the token,
9//     no subagent running, nobody typed since → /clear → resume prompt with the file's full path → idle.
10// It never clears without a fresh checkpoint written for this cycle.
11// Spawn gate (opt-in): a cycle can also start mid-turn. When the main loop starts a subagent at or above the
12//   threshold, the spawn is refused and the refusal carries the checkpoint request; while a cycle runs,
13//   every new main-loop subagent is refused, so a turn that chains subagents drains and ends, and the turn end
14//   clears as above. The clear waits for any background work (timers excluded). Off unless the project
15//   file sets "gateSpawns": true.
16
17const PROJECT_FILE = '.claude/self-clear.json'
18const MIN_THRESHOLD = 200000
19const MAX_THRESHOLD = 850000   // stay well below the engine's own autocompact (~967K seen on 1M models)
20const ASKS = 2                 // checkpoint requests before giving up on this cycle
21const RETRIES = 3              // /clear attempts
22const RETRY_MS = 2000
23const MAX_WAIT_TURNS = 3       // turn ends above threshold + WAIT_MARGIN that may wait on background work before asking anyway
24const WAIT_MARGIN = 100000
25const MAX_MACHINE_REASKS = 3   // non-person inputs during one checkpoint phase before the cycle is dropped
26const MAX_TOTAL_ASKS = 3       // checkpoint asks in one cycle, whatever the cause (more than this is treated as a fault)
27// Prompts from these origins are machinery, not a person typing: they do not cancel a cycle; they only
28// make an earlier checkpoint stale (see prompt.submit).
29// Any other kind (including ones added later) still counts as a person: cancelling is the safe side.
30const MACHINE_ORIGINS = ['plugin', 'task-notification', 'scheduled-trigger', 'peer', 'peer-send-message',
31  'coordinator', 'observer', 'observer-activity', 'auto-continuation']
32// A background shell that only waits (a wake-up timer such as `timeout 300 sleep 290; echo wake`) is not work.
33const TIMER = /^\s*(?:timeout\s+\d+\s+)?sleep\s+\d+(?:\.\d+)?\s*(?:(?:;|&&)\s*echo(?:\s+[\w-]+)?\s*)?$/i
34
35const DEFAULTS = {
36  // Not under .claude/: that is a protected path (writes prompt, or go to the auto-mode classifier).
37  checkpointDir: '.tmp/self-clear',
38  checkpointPrefix: 'checkpoint',
39  checkpointPrompt:
40    'The self-clear mod (a plugin message, not the user\'s words; never quote it as the user): ' +
41    'context is at {tokens}K, so this session will be cleared. ' +
42    'Write a checkpoint now as a NEW file in {dir}/ named "{file}" (do not reuse an existing file). Include ' +
43    'the goal, what is done (with proof), what is in progress, open questions to the user in their exact words, ' +
44    'files changed but not committed, and the exact next step. Put this line in the file exactly: {tokenLine} ' +
45    'Do no other work, then end your turn.',
46  resumePrompt:
47    'The self-clear mod cleared this session after your checkpoint (a plugin message, not the user\'s words; never quote it as the user). ' +
48    'Read {checkpoint} and continue from its next step. If it says you were waiting for the user, ' +
49    'restate the open question in one line and stop.',
50}
51
52// Module state. Resets on reload; a reload mid-cycle simply ends that cycle.
53let phase = 'idle'      // 'idle' | 'checkpoint' | 'clearing'
54let askedAt = 0
55let asks = 0
56let token = ''
57let userSpoke = false   // a prompt that did not come from this mod arrived during the cycle
58let backgroundTasks = 0 // in-flight background work (shell, subagent, monitor, workflow) at the last Stop, timers excluded
59let waitedTurns = 0     // turn ends above threshold + WAIT_MARGIN that waited on background work
60let machineReasks = 0   // non-person inputs during this checkpoint phase
61let lateInput = false   // a non-person input arrived after the clear was scheduled
62let tokenSeq = 0        // makes every token distinct; never reset
63let tokenTag = ''       // session id + time, set by the first token; lets later tokens be made with no await
64let totalAsks = 0       // checkpoint asks in this cycle
65let inputGen = 0        // bumped at once (before any await) by every input that arrives during a cycle
66let gateSuspended = 0   // after a dropped cycle, how many main-loop spawns the gate lets through before it may start a new cycle
67let gateRefusals = 0    // spawns refused in this cycle
68let gateOn = false      // the gate setting at the last config read (prompt.submit must not await a read)
69let busyWaits = 0       // turn ends that found the checkpoint ready but background work still running
70let readyBusy = false   // set after MAX_WAIT_TURNS such turn ends: the gate opens; the clear still waits
71let oldNames = new Set() // note names already in the folder before this cycle's first ask (lower case)
72let skippedNames = []   // fresh notes skipped at the last check because of their file name
73let reusedNames = []    // fresh notes skipped because the name was already in the folder before the cycle
74let snapshotFailed = false // the folder could not be listed at the first ask (other than missing): trust no note
75let abandoned = ''      // token of a clear the watchdog gave up on; a late /clear result for it is ignored
76let blocked = false     // the note folder leads outside the project: no cycle in this session
77let announced = false   // the "on in this project" notice was shown in this session
78let dropStreak = 0      // cycles in a row that ended without a clear
79let pausedBelow = 0     // after DROPS_BEFORE_PAUSE such cycles: no new cycle until the context reaches this
80let lastAskTokens = 0   // context size at the last ask
81
82// Two cycles in a row that end without a clear (for example a folder Claude cannot write to) would
83// otherwise ask again at every turn end. So the mod then waits until the context grows by PAUSE_GROWTH.
84const DROPS_BEFORE_PAUSE = 2
85const CLEAR_WATCHDOG_MS = 60000
86const PAUSE_GROWTH = 100000
87
88function reset() {
89  oldNames = new Set()
90  snapshotFailed = false
91  gateRefusals = 0
92  gateSuspended = 0
93  busyWaits = 0
94  readyBusy = false
95  waitedTurns = 0
96  totalAsks = 0
97  machineReasks = 0
98  lateInput = false
99  phase = 'idle'
100  asks = 0
101  token = ''
102  userSpoke = false
103}
104
105// A cycle that ends without a clear (dropped, cancelled, refused, aborted). The gate then lets the next few
106// main-loop spawns through before it may start a new cycle, so a cycle that cannot finish never holds the
107// work, even inside one long turn. A turn end above the threshold still starts the next cycle at once.
108const GATE_SUSPEND_SPAWNS = 3
109// noNote: the cycle ended because no usable note was written. Only these count toward the pause;
110// a cycle the person interrupted (typing, Esc) does not.
111function drop(noNote = false) {
112  reset()
113  gateSuspended = GATE_SUSPEND_SPAWNS
114  if (!noNote) return
115  dropStreak += 1
116  if (dropStreak >= DROPS_BEFORE_PAUSE) pausedBelow = lastAskTokens + PAUSE_GROWTH
117}
118
119const paused = (tokens) => tokens < pausedBelow
120
121function num(v, fallback, lo, hi) {
122  const n = typeof v === 'number' ? v : NaN
123  return Math.min(hi, Math.max(lo, Number.isFinite(n) ? n : fallback))
124}
125
126function fill(text, values) {
127  return text.replace(/\{(\w+)\}/g, (m, k) => (Object.prototype.hasOwnProperty.call(values, k) ? String(values[k]) : m))
128}
129
130// The project file is repository content: a cloned repo may ship it. So it may only name a checkpoint
131// folder inside the project (plain relative segments, no "..", no absolute or network path) and a plain
132// file-name prefix, and it cannot change the prompts. Anything else falls back to the default.
133const SAFE_SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/
134const SAFE_PREFIX = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
135const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
136const MAX_CHECKPOINT_BYTES = 1000000
137const MAX_FILES_CHECKED = 5
138
139// Windows device names: a folder or file named like this is a device, not a file.
140const RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9]|conin\$|conout\$)(\..*)?$/i
141
142function safeDir(v) {
143  if (typeof v !== 'string') return null
144  const parts = v.replace(/[\\/]+$/, '').split(/[\\/]+/)
145  if (parts.length < 1 || parts.length > 8) return null
146  if (!parts.every((x) => SAFE_SEGMENT.test(x) && !/\.$/.test(x) && !RESERVED.test(x))) return null
147  return parts.join('/')
148}
149
150function safePrefix(v) {
151  return typeof v === 'string' && SAFE_PREFIX.test(v) && !RESERVED.test(v) ? v : null
152}
153
154// The folder name is checked above, but a cloned repo can also commit one of its folders as a link
155// (symlink or junction), or reach one through a case alias. So the real path is what counts: the
156// deepest part of the folder that exists must resolve to a place inside the project's own real path.
157// A path that cannot be resolved is not trusted, but only a resolved path outside the project turns
158// the mod off for the session (a passing lock or permission error just skips this ask).
159function isUnder(real, rootReal) {
160  const win = rootReal.includes('\\')
161  const a = win ? real.toLowerCase() : real
162  const b = win ? rootReal.toLowerCase() : rootReal
163  const sep = win ? '\\' : '/'
164  const base = b.endsWith(sep) ? b : b + sep
165  return a === b || a.startsWith(base)
166}
167
168async function realOf($, path) {
169  const st = await $.fs.stat(path, { resolve: true }).catch(() => null)
170  return st && typeof st.realPath === 'string' ? st.realPath : null
171}
172
173// The project root, keeping the separator of a drive or share root ("C:\", "/") so it stays absolute.
174async function projectRoot($) {
175  const raw = await $.session.root()
176  const sep = raw.includes('\\') ? '\\' : '/'
177  const trimmed = raw.replace(/[\\/]+$/, '')
178  const root = trimmed === '' || /^[A-Za-z]:$/.test(trimmed) ? trimmed + sep : trimmed
179  const join = (rel) => (root.endsWith(sep) ? root : root + sep) + rel.replace(/^[\\/]+/, '').replace(/[\\/]+/g, sep)
180  return { root, sep, join }
181}
182
183// 'inside' | 'outside' | 'unknown'. The deepest part of the folder that exists must resolve inside
184// the project's own real path; a part that does not exist yet is skipped (fs.exists, no error text).
185async function folderPlace($, rel) {
186  try {
187    const { root, join } = await projectRoot($)
188    const rootReal = await realOf($, root)
189    if (!rootReal) return 'unknown'
190    const parts = rel.split('/')
191    for (let n = parts.length; n >= 1; n -= 1) {
192      const path = join(parts.slice(0, n).join('/'))
193      if (!(await $.fs.exists(path))) continue
194      const real = await realOf($, path)
195      if (!real) return 'unknown'
196      return isUnder(real, rootReal) ? 'inside' : 'outside'
197    }
198    return 'inside'   // nothing of the folder exists yet: Claude creates it inside the project root
199  } catch (err) {
200    return 'unknown'
201  }
202}
203
204async function insideProject($, rel) {
205  return (await folderPlace($, rel)) === 'inside'
206}
207
208// Project paths resolve from the project root, not the shell's cwd: a `cd` must not hide them.
209// Joined with the root's own separator, so a Windows root gives a plain Windows path.
210// Only relative paths reach here (see safeDir): the checkpoint folder always lives inside the project.
211async function rootPath($, rel) {
212  return (await projectRoot($)).join(rel)
213}
214
215// null: no file. { bad: reason }: a file that is not a small, plain JSON object. Otherwise the object.
216// The file is repository content, so it must be a plain file (not a link, a device or a pipe) and small.
217const MAX_PROJECT_FILE_BYTES = 65536
218async function readProjectFile($) {
219  try {
220    const path = await rootPath($, PROJECT_FILE)
221    if (!(await $.fs.exists(path))) return null
222    const st = await $.fs.stat(path)
223    if (!st || st.kind !== 'file' || st.isLink || st.size > MAX_PROJECT_FILE_BYTES) return { bad: 'not a small plain file' }
224    const parsed = JSON.parse(String(await $.fs.read(path)).replace(/^\uFEFF/, ''))
225    return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : { bad: 'not a JSON object' }
226  } catch (err) {
227    $.ui.log('self-clear: ignoring ' + PROJECT_FILE + ': ' + err, { to: 'debug' })
228    return { bad: 'not valid JSON' }
229  }
230}
231
232async function loadConfig($, options) {
233  const cfg = await readConfig($, options)
234  gateOn = cfg.enabled && cfg.gateSpawns && !blocked
235  // A project file (possibly from a cloned repo) turned the mod on: say so once, with what it set.
236  if (cfg.enabled && !announced) {
237    announced = true
238    $.ui.toast('self-clear is on in this project: clears at ' + Math.round(cfg.threshold / 1000) + 'K, auto-resume ' +
239      (cfg.autoResume ? 'on' : 'off') + ', spawn gate ' + (cfg.gateSpawns ? 'on' : 'off') + '. Type /self-clear for details.')
240  }
241  return cfg
242}
243
244async function readConfig($, options) {
245  const read = await readProjectFile($)
246  const bad = read && read.bad ? read.bad : ''
247  const file = bad ? null : read
248  const p = file || {}
249  const pick = (v, d) => (v === undefined || v === null ? d : v)
250  return {
251    hasFile: read !== null,
252    bad,
253    enabled: file !== null && p.enabled === true,
254    threshold: num(pick(p.thresholdTokens, options.threshold_tokens), 500000, MIN_THRESHOLD, MAX_THRESHOLD),
255    autoResume: pick(p.autoResume, pick(options.auto_resume, false)) === true,
256    dir: safeDir(p.checkpointDir) || DEFAULTS.checkpointDir,
257    prefix: safePrefix(p.checkpointPrefix) || DEFAULTS.checkpointPrefix,
258    checkpointPrompt: DEFAULTS.checkpointPrompt,
259    resumePrompt: DEFAULTS.resumePrompt,
260    gateSpawns: p.gateSpawns === true || p.gateSpawns === 'true',   // off unless switched on
261  }
262}
263
264async function contextTokens($) {
265  const usage = await $.session.usage()
266  return (usage && usage.context && usage.context.tokens) || 0
267}
268
269// Work still running in the background — subagents (for example reviewers or helpers) and the
270// background tasks the last Stop event listed (run_in_background shells such as a test run,
271// monitors, workflows). Clearing now would lose track of the results, so the cycle waits.
272async function runningWork($) {
273  let agents = 0
274  try {
275    agents = (await $.agent.list()).filter((a) => a.status === 'running').length
276  } catch (err) {
277    agents = 0
278  }
279  return agents + backgroundTasks
280}
281
282// Absolute path of the newest checkpoint file changed since `since` that carries this cycle's token;
283// null when there is none. The token stops another session's checkpoint, or a stale or half-written
284// file, from passing as this session's.
285// A note that already sat in the folder before this cycle's first ask (for example one a cloned repo
286// ships) never counts, even if Claude appends the token to it: the ask is for a new file.
287async function snapshotOld($, cfg, before) {
288  try {
289    if (!(await insideProject($, cfg.dir))) { snapshotFailed = true; return }   // could not check: trust no note
290    const dir = await rootPath($, cfg.dir)
291    if (!(await $.fs.exists(dir))) { oldNames = new Set(); return }   // no folder yet: nothing old in it
292    const entries = await $.fs.list(dir)
293    // Every name already there counts as old, whatever its date (an archive can carry a future one).
294    oldNames = new Set(entries.map((f) => f.name.toLowerCase()))
295  } catch (err) {
296    // No folder yet: nothing old in it. Any other failure: trust no note in this cycle.
297    if (!String(err).includes('ENOENT')) snapshotFailed = true
298    oldNames = new Set()
299  }
300}
301
302const prefixed = (f, cfg) => f.name.toLowerCase().startsWith(cfg.prefix.toLowerCase())
303
304async function freshCheckpoint($, cfg, since, want) {
305  skippedNames = []
306  reusedNames = []
307  if (snapshotFailed) return null
308  try {
309    if (!(await insideProject($, cfg.dir))) {
310      $.ui.log('self-clear: ' + cfg.dir + ' leads outside the project; not reading it', { to: 'debug' })
311      return null
312    }
313    const rootReal = await realOf($, (await projectRoot($)).root)
314    const dir = await rootPath($, cfg.dir)
315    const entries = await $.fs.list(dir)
316    const recent = entries.filter((f) => f.kind === 'file' && !f.isLink && prefixed(f, cfg) && f.mtimeMs >= since)
317    skippedNames = recent.filter((f) => !SAFE_NAME.test(f.name) || RESERVED.test(f.name)).map((f) => f.name)
318    reusedNames = recent.filter((f) => oldNames.has(f.name.toLowerCase())).map((f) => f.name)
319    const fresh = recent
320      .filter((f) => SAFE_NAME.test(f.name) && !RESERVED.test(f.name) && !oldNames.has(f.name.toLowerCase())
321        && !(f.size > MAX_CHECKPOINT_BYTES))
322      .sort((a, b) => b.mtimeMs - a.mtimeMs)
323      .slice(0, MAX_FILES_CHECKED)
324    for (const f of fresh) {
325      const path = dir + (dir.includes('\\') ? '\\' : '/') + f.name
326      const real = await realOf($, path)
327      if (!real || !rootReal || !isUnder(real, rootReal)) continue
328      if (String(await $.fs.read(path)).includes(want)) return path
329    }
330    return null
331  } catch (err) {
332    $.ui.log('self-clear: cannot check ' + cfg.dir + ': ' + err, { to: 'debug' })
333    return null
334  }
335}
336
337// A new token every time one is needed: a checkpoint file is often rewritten in place, so a
338// token line from an earlier ask can still be in it and must not pass as this one.
339async function newToken($) {
340  if (!tokenTag) tokenTag = (await $.session.id()).slice(0, 8) + '-' + (await $.clock.now()).toString(36)
341  return nextToken()
342}
343
344// The cycle's token, made once: the only awaits come before the re-check, so two callers that race
345// (parallel spawns, "/self-clear now") end up with the same token.
346async function ensureToken($) {
347  if (!tokenTag) tokenTag = (await $.session.id()).slice(0, 8) + '-' + (await $.clock.now()).toString(36)
348  if (!token) token = nextToken()
349  return token
350}
351
352// Synchronous: used where an await would leave a moment in which the old token still counts.
353function nextToken() {
354  tokenSeq += 1
355  return 'self-clear-token: ' + (tokenTag || 'x') + '-' + tokenSeq
356}
357
358// A file name made by the mod for this ask: prefix, then the UTC date and time of the ask.
359function noteName(cfg) {
360  const d = new Date(askedAt || 0)
361  const two = (n) => String(n).padStart(2, '0')
362  return cfg.prefix + '-' + d.getUTCFullYear() + two(d.getUTCMonth() + 1) + two(d.getUTCDate()) + '-' +
363    two(d.getUTCHours()) + two(d.getUTCMinutes()) + two(d.getUTCSeconds()) + '.md'
364}
365
366function requestText(cfg, tokens, tokenLine) {
367  return fill(cfg.checkpointPrompt, { tokens: Math.round(tokens / 1000), dir: cfg.dir, prefix: cfg.prefix, file: noteName(cfg), tokenLine })
368}
369
370// Counts one ask and enters the checkpoint phase; false when this cycle has asked too often (it is dropped).
371async function beginAsk($, tokens) {
372  if (totalAsks >= MAX_TOTAL_ASKS) {
373    drop(true)
374    $.ui.toast('self-clear: asked ' + MAX_TOTAL_ASKS + ' times in this cycle without a usable checkpoint; not clearing')
375    return false
376  }
377  // State first, with no await before it: a parallel caller sees the checkpoint phase at once.
378  totalAsks += 1
379  asks += 1
380  phase = 'checkpoint'
381  lastAskTokens = tokens
382  askedAt = await $.clock.now()
383  await ensureToken($)
384  $.ui.toast('self-clear: context ' + Math.round(tokens / 1000) + 'K, asking for a checkpoint')
385  return true
386}
387
388// The note folder must resolve inside the project before Claude is told to write there. If it does
389// not (a link or alias a cloned repo ships), the mod stays off for this session and says so once.
390async function folderOk($, cfg) {
391  if (blocked) return false
392  const place = await folderPlace($, cfg.dir)
393  if (place === 'inside') return true
394  if (place === 'unknown') {
395    $.ui.log('self-clear: could not check where ' + cfg.dir + ' leads; skipping this ask', { to: 'debug' })
396    return false
397  }
398  blocked = true
399  gateOn = false
400  $.ui.toast('self-clear: the note folder ' + cfg.dir + ' leads outside this project (a link?), so self-clear stays off in this session. Fix checkpointDir in .claude/self-clear.json and restart')
401  return false
402}
403
404async function askForCheckpoint($, cfg, tokens) {
405  if (!(await folderOk($, cfg))) { reset(); return }
406  const first = totalAsks === 0
407  if (!(await beginAsk($, tokens))) return
408  if (first) await snapshotOld($, cfg, askedAt)
409  // Not awaited: it resolves only when the new turn starts. A refusal ends the cycle.
410  $.prompt.submit({ text: requestText(cfg, tokens, token) })
411    .catch((err) => { drop(true); $.ui.log('self-clear: checkpoint request not sent: ' + err) })
412}
413
414// Spawn gate. The cycle acts only at a turn end, and a main loop that chains subagents in one turn
415// (foreground fires, one after another) never reaches one. So once a clear is due, the main loop may
416// not start a new subagent: the refusal reaches the model at once, mid-turn, and carries the checkpoint
417// request with this cycle's token. Subagents already running finish; with nothing new started the work
418// drains, the model ends its turn, and the turn end clears as before.
419const GATE_HEAD = 'self-clear: no new subagent starts until this session is cleared. Subagents already running keep running. '
420const GATE_CLEARING = 'self-clear: this session is being cleared now; start no subagent and end your turn.'
421const GATE_AGAIN = 'self-clear: refused again. Start no subagent. Write the checkpoint with this line exactly, then end your turn:\n{tokenLine}'
422
423// The refusal: the full request the first time in a cycle, then a short one that still names the token.
424async function refuse($, cfg, tokens) {
425  const tokenLine = await ensureToken($)
426  gateRefusals += 1
427  if (gateRefusals > 1) {
428    $.ui.log('self-clear: spawn refused again (' + gateRefusals + ' in this cycle)', { to: 'debug' })
429    return { deny: fill(GATE_AGAIN, { tokenLine }) }
430  }
431  return { deny: GATE_HEAD + requestText(cfg, tokens, tokenLine) }
432}
433
434async function clearThenResume($, cfg, checkpoint, attempt, gen, want) {
435  // Input arrived between the checkpoint and now: the checkpoint no longer covers the session.
436  if (userSpoke || lateInput || gen !== inputGen || token !== want) {
437    const why = userSpoke ? 'you sent a message' : 'new input arrived (a job or a subagent finished)'
438    drop()
439    $.ui.toast('self-clear: ' + why + ', so this clear was cancelled; it will ask again later')
440    return
441  }
442  try {
443    // A refused run rejects (CommandRunResult has no deny field); the catch below retries.
444    // A /clear that never settles must not leave the session stuck in 'clearing'.
445    if (attempt === 1) {
446      $.clock.after(CLEAR_WATCHDOG_MS, () => {
447        if (phase === 'clearing' && token === want) {
448          abandoned = want
449          $.ui.log('self-clear: /clear did not finish; dropping this cycle')
450          drop()
451        }
452      })
453    }
454    await $.command.run({ command: 'clear' })
455    if (abandoned === want) {
456      // Finished after the watchdog gave up. The session was cleared, so resume, unless a newer cycle runs.
457      if (phase === 'idle' && cfg.autoResume) {
458        $.prompt.submit({ text: fill(cfg.resumePrompt, { checkpoint }) })
459          .catch((err) => $.ui.log('self-clear: resume prompt not sent: ' + err))
460      }
461      return
462    }
463    reset()
464    dropStreak = 0
465    pausedBelow = 0
466    $.ui.toast('self-clear: cleared after checkpoint ' + checkpoint + (cfg.autoResume ? ', resuming' : ''))
467    if (cfg.autoResume) {
468      $.prompt.submit({ text: fill(cfg.resumePrompt, { checkpoint }) })
469        .catch((err) => $.ui.log('self-clear: resume prompt not sent: ' + err))
470    }
471  } catch (err) {
472    if (attempt < RETRIES) {
473      $.clock.after(RETRY_MS, () => clearThenResume($, cfg, checkpoint, attempt + 1, gen, want))
474      return
475    }
476    drop()
477    $.ui.log('self-clear: /clear did not run: ' + err)
478  }
479}
480
481export function register(on, options) {
482  on('session.start', async ($, e, next) => {
483    if (!tokenTag) tokenTag = (await $.session.id()).slice(0, 8) + '-' + (await $.clock.now()).toString(36)
484    await $.command.register({ name: 'self-clear', description: 'Show self-clear status; "/self-clear now" starts the checkpoint-and-clear cycle', argumentHint: '[now]' })
485    // Read the project file once at the start, so a project that turns the mod on says so right away.
486    try { await loadConfig($, options) } catch (err) { $.ui.log('self-clear: ' + err, { to: 'debug' }) }
487    return next(e)
488  })
489
490  // Stop lists the session's in-flight background work; remember how much is still running.
491  on('classic.Stop', async ($, e, next) => {
492    if (!e.agent_id) {
493      backgroundTasks = Array.isArray(e.background_tasks)
494        ? e.background_tasks.filter((t) => !(t && typeof t.command === 'string' && TIMER.test(t.command))).length
495        : 0
496    }
497    return next(e)
498  })
499
500  // Spawn gate: see GATE_HEAD. Main loop only; a subagent's own subagents are part of its running work.
501  // A failing guard is skipped by the engine and the subagent starts: the gate fails open, never shut.
502  on('agent.spawn', async ($, e, next) => {
503    // Not gated: a subagent's own subagents, and a workflow's agents (a running job; its text goes to the script).
504    if (e.parentAgentId || e.workflow) return next(e)
505    // The person spoke during the cycle: their work comes first, and this cycle could not clear anyway.
506    if (userSpoke && phase === 'checkpoint' && gateOn) {
507      drop()
508      gateSuspended -= 1   // this spawn is the first of the ones let through
509      $.ui.toast('self-clear: you sent a message during the checkpoint, so this cycle stopped and your work goes first; it will ask again later')
510      return next(e)
511    }
512    if (phase === 'idle') {
513      if (gateSuspended > 0) {
514        gateSuspended -= 1
515        return next(e)
516      }
517      const tokens = await contextTokens($)
518      if (tokens < MIN_THRESHOLD) return next(e)            // no config read on the common path
519      const cfg = await loadConfig($, options)
520      if (!cfg.enabled || !cfg.gateSpawns || tokens < cfg.threshold || paused(tokens)) return next(e)
521      if (phase !== 'idle') return refuse($, cfg, tokens)   // a parallel spawn started the cycle meanwhile
522      if (!(await folderOk($, cfg))) return next(e)
523      if (phase !== 'idle') return refuse($, cfg, tokens)
524      if (!(await beginAsk($, tokens))) return next(e)       // unreachable while idle (totalAsks is 0); fail open
525      await snapshotOld($, cfg, askedAt)
526      return refuse($, cfg, tokens)
527    }
528    if (readyBusy) return next(e)
529    const cfg = await loadConfig($, options)
530    if (!cfg.enabled || !cfg.gateSpawns) return next(e)
531    if (phase === 'clearing') return { deny: GATE_CLEARING }
532    return refuse($, cfg, await contextTokens($))
533  })
534
535  // Prompts run in order. A prompt from this mod (the checkpoint request) starts a turn whose
536  // checkpoint covers everything before it; any other prompt after it makes that checkpoint stale.
537  on('prompt.submit', async ($, e, next) => {
538    if (phase !== 'idle') {
539      inputGen += 1   // first, with no await before it: a turn end already in flight must see it
540      const kind = e.origin && e.origin.kind
541      // Only this mod's own ask counts as its own; another plugin's prompt is machinery, or a person
542      // when it is sent as the person's words (asUser).
543      const own = kind === 'plugin' && e.origin.name === $.plugin.name && !e.origin.asUser
544      if (own) {
545        userSpoke = false   // this mod's own ask: its checkpoint covers what came before
546      } else if (MACHINE_ORIGINS.includes(kind) && !(e.origin && e.origin.asUser)) {
547        // New input that is not a person (a job finished, a subagent handed back): do not cancel,
548        // but a checkpoint written before it no longer covers the session. Only a newer one, with a
549        // new token, counts, and the cycle keeps one more ask for it (at most MAX_MACHINE_REASKS).
550        if (phase === 'checkpoint') {
551          machineReasks += 1
552          // With the gate on, no new subagent starts, so hand-backs during the drain are bounded by the subagents
553          // already running: they still make the checkpoint stale, but the cycle is dropped only when the gate is off.
554          if (machineReasks > MAX_MACHINE_REASKS && !gateOn) {
555            drop()
556            $.ui.toast('self-clear: input keeps arriving during the checkpoint, so this cycle was dropped; it will ask again later')
557          } else {
558            // A new token at once, with no await before it: only a checkpoint written after this
559            // input, carrying this token, counts. askedAt follows; a file with the new token cannot
560            // exist before the next ask, so its later update leaves no gap.
561            token = nextToken()
562            askedAt = await $.clock.now()
563            if (asks >= ASKS) asks = ASKS - 1
564          }
565        } else if (phase === 'clearing') {
566          lateInput = true   // too late for a new checkpoint: cancel
567        }
568      } else {
569        userSpoke = true
570      }
571    }
572    return next(e)
573  })
574
575  on('turn.complete', async ($, e, next) => {
576    // Taken before anything is awaited: any input from here on makes this turn end's checks stale.
577    const gen = inputGen
578    const want = token
579    const result = await next(e)
580    // Main conversation only.
581    if (e.agentId) return result
582    if (phase === 'clearing') return result
583    if (e.isAborted) {
584      // The person stopped Claude: abandon any cycle in progress.
585      if (phase !== 'idle') {
586        $.ui.log('self-clear: stopped by the user; not clearing')
587        drop()
588      }
589      return result
590    }
591    const tokens = await contextTokens($)
592    if (phase === 'idle' && tokens < MIN_THRESHOLD) {
593      pausedBelow = 0   // well below any threshold (after a clear or a compaction): the pause is over
594      dropStreak = 0
595      return result
596    }
597    const cfg = await loadConfig($, options)
598    if (!cfg.enabled) {
599      reset()
600      return result
601    }
602    if (phase === 'checkpoint') {
603      const checkpoint = await freshCheckpoint($, cfg, askedAt, token)
604      if (checkpoint) {
605        const busy = await runningWork($)
606        if (busy > 0) {
607          $.ui.status('self-clear: checkpoint ready; waiting for ' + busy + ' background job(s) before /clear (stop that work to let the clear run)')
608          // Work that never ends (a dev server, a watcher) must not hold the spawn gate shut for good.
609          if (++busyWaits >= MAX_WAIT_TURNS && !readyBusy) {
610            readyBusy = true
611            $.ui.log('self-clear: background work keeps running; new subagents may start again, the clear still waits for it')
612          }
613          return result
614        }
615        // An input arrived while this turn end was checking: the checkpoint it found may not cover it.
616        // Do not clear now; the next turn end checks again against the token that input set.
617        if (gen !== inputGen || token !== want) return result
618        phase = 'clearing'
619        $.clock.after(0, () => clearThenResume($, cfg, checkpoint, 1, gen, want))
620      } else if (asks < ASKS) {
621        await askForCheckpoint($, cfg, tokens)
622      } else {
623        const names = skippedNames.slice(0, 3).join(', ')
624        const reused = reusedNames.slice(0, 3).join(', ')
625        drop(true)
626        $.ui.toast(names
627          ? 'self-clear: found ' + names + ', but a note name may only use letters, digits, ".", "_" and "-"; not clearing. It asks again later'
628          : reused ? 'self-clear: found ' + reused + ', but that note was already in the folder before this cycle; a new file is needed. Not clearing. It asks again later'
629          : 'self-clear: no new checkpoint with this cycle\'s one-time code was written; not clearing. It asks again later')
630      }
631      return result
632    }
633    if (blocked) return result
634    if (tokens < cfg.threshold) {
635      pausedBelow = 0   // the context went back below the threshold (a clear or a compaction): the pause is over
636      dropStreak = 0
637      waitedTurns = 0
638      return result
639    }
640    if (paused(tokens)) return result
641    if ((await runningWork($)) === 0) {
642      waitedTurns = 0
643      await askForCheckpoint($, cfg, tokens)
644    } else if (tokens >= cfg.threshold + WAIT_MARGIN && ++waitedTurns >= MAX_WAIT_TURNS) {
645      // Background work never seems to end; ask now. The clear itself still waits for running work.
646      waitedTurns = 0
647      await askForCheckpoint($, cfg, tokens)
648    }
649    return result
650  })
651
652  on('command.run', { command: 'self-clear' }, async ($, e) => {
653    const cfg = await loadConfig($, options)
654    const tokens = await contextTokens($)
655    if (String(e.args || '').trim() === 'now') {
656      if (!cfg.enabled) return { text: 'self-clear is off here: add {"enabled": true} to ' + PROJECT_FILE + '.' }
657      if (phase !== 'idle') return { text: 'A cycle is already running (' + phase + ').' }
658      if (!(await folderOk($, cfg))) {
659        return { text: blocked
660          ? 'self-clear is off in this session: the note folder ' + cfg.dir + ' leads outside this project.'
661          : 'self-clear could not check where the note folder ' + cfg.dir + ' leads; try again in a moment.' }
662      }
663      reset()
664      pausedBelow = 0
665      dropStreak = 0
666      phase = 'checkpoint'   // first: a spawn in between is refused (and gets the same token), it does not start a second ask
667      askedAt = await $.clock.now()
668      await ensureToken($)
669      // Sent after this command returns: a prompt cannot start from inside the command's own hook.
670      $.clock.after(0, () => askForCheckpoint($, cfg, tokens))
671      return { text: 'Asking for a checkpoint; /clear follows once it is written.' }
672    }
673    return {
674      text:
675        'context ' + Math.round(tokens / 1000) + 'K · threshold ' + Math.round(cfg.threshold / 1000) + 'K · ' +
676        (blocked ? 'off in this session (the note folder leads outside the project)'
677          : cfg.enabled ? 'on' : cfg.bad ? 'off (' + PROJECT_FILE + ' is ' + cfg.bad + ')'
678          : cfg.hasFile ? 'off in this project' : 'off (no ' + PROJECT_FILE + ')') +
679        (pausedBelow > tokens ? ' · paused until ' + Math.round(pausedBelow / 1000) + 'K' : '') +
680        ' · auto-resume ' + (cfg.autoResume ? 'on' : 'off') +
681        ' · spawn gate ' + (cfg.gateSpawns ? 'on' : 'off') +
682        ' · checkpoint ' + cfg.dir + '/' + cfg.prefix + '* · phase ' + phase,
683    }
684  })
685}
686