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…

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:
/plugin.This project is not affiliated with Anthropic and is not endorsed by Anthropic. "Claude" and "Claude Code" are trademarks of Anthropic, PBC.
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).
off (no .claude/self-clear.json), the file is missing or in the wrong folder. It must be inside the project you opened.off (.claude/self-clear.json is not valid JSON), check the quotes and commas in the file.off in this project, the file is there but does not say "enabled": true (write true without quotes).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.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.
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:
/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.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 for | Before (Sep 28 to Oct 6, 2026, no self-clear) | Current settings (Oct 12 to 13, 2026) | Change |
|---|---|---|---|
| Reading the saved conversation (cache read) | 80.5 | 54.2 | −26.3 (this item went down 33%) |
| Saving to the cache (cache write; every clear adds a full re-save) | 9.8 | 14.0 | +4.2 (this item went up 43%) |
| Claude's answer (output) | 9.7 | 12.0 | +2.3 (this item went up 24%) |
| Total | 100 | 80.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.
"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,558 | 5,978 | 1,999 |
| Context per step: middle value / 90th percentile (9 in 10 steps were smaller than this) | 455K / 852K | 352K / 594K | 336K / 489K |
| Auto-compactions | 19 | 0 | 0 |
| Clears by self-clear | 0 | 37 | 16 |
| Cost per step (before = 100) | 100 | 83 | 80 |
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:
autoResume and gateSpawns on. Oct 13 is not a full day.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.
All settings go in .claude/self-clear.json. Write real JSON values, like true and 500000, not text in quotes.
| Key | Default | What it does |
|---|---|---|
enabled | false | Must be true, or the mod stays off in this project |
thresholdTokens | 500000 | Start a cycle after a turn that ends at or above this size. Kept between 200,000 and 850,000 |
autoResume | false | After /clear, tell Claude to continue from the note. Use true when nobody is watching |
gateSpawns | false | Recommended for agents that start many subagents. See "Spawn gate" below |
checkpointDir | .tmp/self-clear | Folder 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 |
checkpointPrefix | checkpoint | Note 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.
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:
/clear itself still waits for that work."autoResume": true, so a run that stops mid-turn picks up again after the clear./self-clear shows the conversation size, the threshold, the settings and the current phase./self-clear now starts a cycle right away..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.[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./self-clear shows the pause, and /self-clear now starts a cycle anyway..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)./self-clear says on but no cycle ever starts past the threshold, this check is one possible cause.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.
CLAUDE_CODE_DISABLE_1M_CONTEXT=1) reach auto-compaction first./clear waits as long as it runs. /self-clear shows the phase. Stop that work to let the cycle finish.sleep timers in a shell are ignored. A PowerShell Start-Sleep timer, a monitor or a script that waits still counts as background work./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./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.\\server\share\project) cannot be checked, so cycles do not start there.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.--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.
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.
MIT, see LICENSE.
hooks/register.js 686 lines1// 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