Keeps the prompt cache warm while you are idle, and when a session gets too big, writes a handover note, clears, and carries on from the note. Shows its state…

A Claude Code mod that looks after long sessions:
/clear, and starts the fresh session from that note. Notes go in <project>/.claude/handovers/.In a Claude Code terminal session:
/plugin install session-keeper --marketplace OutHypeR/claude-session-keeper
Answer y to add the marketplace, then pick the user scope. It runs from then on.
Or run it from a folder: clone this repository and add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (several folders are separated by ; on Windows, : elsewhere), then restart Claude Code.
| Command | What it does |
|---|---|
/autoclear on, /autoclear off, /autoclear | Turns the automatic handover and clear on or off (toggle with no argument). On by default. Saved across sessions. |
/handover | Says whether this session would clear now, and why (model, tokens, turns, cache age). |
/handover now | Writes a handover and clears right away, whatever the limits. Works with autoclear off. |
/away on, /away off, /away | Away mode: stops the keep-warm pings, for when you will be gone for hours. |
Limits depend on the model (bigger models hand over sooner):
| Model | Tokens | Turns |
|---|---|---|
| Opus / Fable | 350k | 60 |
| Sonnet | 500k | 100 |
| Haiku | 500k | 150 |
| other | 150k | 80 |
It never clears a session under 30k tokens. The token limits are not capped by the model's context window: a session whose window is smaller than its limit hands over only on the turn limit, or is compacted by Claude Code itself when the window fills. If the cache has already gone cold (more than an hour idle), the limits drop to 60%, because the next turn re-reads everything anyway. After a clear it waits at least 10 minutes before it can hand over again. Change any of these numbers at the top of hooks/register.tsx.
/away on when you leave for a long time./away on to stop the pings, and keep the handover part. (The interval is PING_EVERY_MS at the top of hooks/register.tsx.)claude --resume). The handover note is what carries the work forward, so a clear mid-task can lose detail the note did not capture. Turn /autoclear off if you would rather clear by hand..claude/handovers/ to your project's .gitignore if you do not want them committed.MIT. See LICENSE.
hooks/register.tsx 356 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { View } from '../types'
5
6// ---- Tuning: change these numbers to taste -------------------------------
7const MIN = 60 * 1000
8const CACHE_TTL_MS = 60 * MIN // the 1-hour prompt cache
9const PING_EVERY_MS = 58 * MIN // keep-warm period
10const MAX_PINGS_IN_A_ROW = 8 // then let the cache lapse; any real turn resets it
11const MIN_TOKENS_TO_PING = 20_000 // not worth warming a tiny context
12const MIN_TOKENS_TO_CLEAR = 30_000 // never clear a small context
13const COLD_FACTOR = 0.6 // cache lapsed: the next turn re-bills everything, so clear sooner
14const RETRY_AFTER_FAIL_MS = 30 * MIN
15const WRITE_TIMEOUT_MS = 15 * MIN // give up on a handover note that never appears
16const COOLDOWN_AFTER_CLEAR_MS = 10 * MIN // never hand over twice back to back
17const CHECK_EVERY_MS = MIN
18
19type Limits = { tokens: number; turns: number }
20
21// Bigger, pricier models hand over sooner.
22const limitsFor = (model: string, _window: number): Limits => {
23 const m = model.toLowerCase()
24 const base: Limits = /fable|opus/.test(m)
25 ? { tokens: 350_000, turns: 60 }
26 : /sonnet/.test(m)
27 ? { tokens: 500_000, turns: 100 }
28 : /haiku/.test(m)
29 ? { tokens: 500_000, turns: 150 }
30 : { tokens: 150_000, turns: 80 }
31 return base // no cap from the context window; Claude Code's own compaction still applies when the window fills
32}
33
34type Verdict = {
35 clear: boolean
36 why: string
37 model: string
38 tokens: number
39 turns: number
40 ageMin: number
41 limits: Limits
42 cold: boolean
43}
44
45const assess = async ($: EngineInterface, lastTouch: number): Promise<Verdict> => {
46 const [usage, model, turns, now] = await Promise.all([
47 $.session.usage(),
48 $.session.model(),
49 $.session.turns(),
50 $.clock.now(),
51 ])
52 const tokens = usage.context.tokens ?? 0
53 const ageMs = Math.max(0, now - lastTouch)
54 const cold = ageMs > CACHE_TTL_MS && tokens >= MIN_TOKENS_TO_CLEAR
55 const base = limitsFor(model, usage.context.window)
56 const k = cold ? COLD_FACTOR : 1
57 const limits: Limits = { tokens: Math.round(base.tokens * k), turns: Math.round(base.turns * k) }
58 const out = { model, tokens, turns, ageMin: Math.round(ageMs / MIN), limits, cold }
59 const tag = cold ? ' (cache cold)' : ''
60
61 if (tokens < MIN_TOKENS_TO_CLEAR) return { ...out, clear: false, why: 'context is small' }
62 if (tokens >= limits.tokens) {
63 return { ...out, clear: true, why: `${Math.round(tokens / 1000)}k tokens >= ${Math.round(limits.tokens / 1000)}k${tag}` }
64 }
65 if (turns >= limits.turns) return { ...out, clear: true, why: `${turns} turns >= ${limits.turns}${tag}` }
66 return { ...out, clear: false, why: 'within limits' }
67}
68
69const summary = (v: Verdict) =>
70 `${v.model}: ${Math.round(v.tokens / 1000)}k of ${Math.round(v.limits.tokens / 1000)}k tokens, ` +
71 `${v.turns} of ${v.limits.turns} turns, cache ${v.cold ? 'cold' : 'warm'} (${v.ageMin} min since the last reply). ` +
72 `${v.clear ? 'Should clear' : 'Keep going'}: ${v.why}.`
73
74const handoverPrompt = (path: string, why: string) =>
75 [
76 `This session is about to be cleared and resumed from a handover note (session-keeper: ${why}). Write that note now to: ${path}`,
77 '',
78 'Create the folder if needed. Make it complete enough that a fresh session with no memory of this one can carry on:',
79 '1. The goal and what the user asked for, in their words where it matters.',
80 '2. What is done: files changed or created (full paths), commands run, results.',
81 '3. Decisions made and the reason for each, plus alternatives rejected.',
82 '4. Current state: what works, what is broken or unverified, anything half-finished.',
83 '5. Next steps, in order, and any open questions for the user.',
84 '6. Rules or preferences the user gave that still apply.',
85 'Never put keys, tokens or passwords in it. When the file is written, reply with only: Handover written.',
86 ].join('\n')
87
88const resumePrompt = (path: string) =>
89 `This is a continuation. Your previous session was cleared on purpose and left a handover note at ${path}. ` +
90 'Read it in full, then give me two lines: the goal as you understand it and the next step you will take. Then carry on.'
91
92// Mutable state lives at the top level so the helpers below can take `$`.
93// A hot reload resets it, so phase and path are mirrored into the session's own state (see
94// setPhase / restorePhase) and a reload mid-handover still finishes the clear.
95const S = {
96 lastTouch: Date.now(),
97 isWorking: false,
98 phase: 'idle' as 'idle' | 'writing' | 'clearing',
99 handoverPath: '',
100 writingSince: 0,
101 holdUntil: 0,
102 pings: 0,
103 isPingFailureShown: false,
104}
105
106const view = atom({ plugin: 'session-keeper', key: 'view' } as const, null)
107// Handover phase, kept in the SESSION's own state. It must not go in $.store: that is shared by
108// every open session, so one session's handover made the others clear and resume from its note.
109const phaseAtom = atom({ plugin: 'session-keeper', key: 'phase' } as const, '')
110
111const setPhase = async ($: EngineInterface, phase: typeof S.phase, path = S.handoverPath) => {
112 S.phase = phase
113 S.handoverPath = path
114 await update($, phaseAtom, () => (phase === 'idle' ? '' : `${phase}|${path}`))
115}
116
117const restorePhase = async ($: EngineInterface) => {
118 if (S.phase !== 'idle') return
119 const saved = String((await read($, phaseAtom)) ?? '')
120 const [phase, ...rest] = saved.split('|')
121 if (phase === 'writing') {
122 S.phase = phase
123 S.handoverPath = rest.join('|')
124 S.writingSince = await $.clock.now() // the start time was lost with the reload; count from now
125 } else if (phase === 'clearing') {
126 await update($, phaseAtom, () => '') // a reload hit mid-clear; the clear itself is done by now
127 }
128}
129
130const isAway = async ($: EngineInterface) => (await $.store.get('away')) === true
131
132// On unless switched off with /autoclear off. Only the automatic clear is gated; /handover now still works.
133const isAutoclear = async ($: EngineInterface) => (await $.store.get('autoclear')) !== false
134
135// What the row above the prompt draws; written from timers and handlers only.
136const refreshView = async ($: EngineInterface) => {
137 const [v, away, autoclear, now] = await Promise.all([assess($, S.lastTouch), isAway($), isAutoclear($), $.clock.now()])
138 const idle = now - S.lastTouch
139 const next: View = {
140 cold: v.cold || idle > CACHE_TTL_MS,
141 ageMin: v.ageMin,
142 nextPingMin: away || S.pings >= MAX_PINGS_IN_A_ROW ? null : Math.max(0, Math.ceil((PING_EVERY_MS - idle) / MIN)),
143 away,
144 autoclear,
145 tokens: v.tokens,
146 tokenLimit: v.limits.tokens,
147 turns: v.turns,
148 turnLimit: v.limits.turns,
149 isWriting: S.phase !== 'idle',
150 }
151 const prev = await read($, view)
152 if (!prev || JSON.stringify(prev) !== JSON.stringify(next)) await update($, view, () => next)
153}
154
155const startHandover = async ($: EngineInterface, why: string) => {
156 if (S.phase !== 'idle' || S.isWorking) return
157 const [cwd, now] = await Promise.all([$.session.cwd(), $.clock.now()])
158 const stamp = new Date(now).toISOString().replace(/[:.]/g, '-')
159 S.handoverPath = `${cwd.split('\\').join('/')}/.claude/handovers/${stamp}.md`
160 S.writingSince = now
161 await setPhase($, 'writing', S.handoverPath)
162 $.ui.toast(`session-keeper: writing a handover (${why})`)
163 const sent = await $.prompt.submit({ text: handoverPrompt(S.handoverPath, why), asUser: true })
164 if (sent.drop) {
165 await setPhase($, 'idle')
166 S.holdUntil = now + RETRY_AFTER_FAIL_MS
167 $.ui.toast(`session-keeper: handover not sent: ${sent.drop}`)
168 }
169}
170
171const finishHandover = async ($: EngineInterface) => {
172 const path = S.handoverPath
173 const now = await $.clock.now()
174 if (!(await $.fs.exists(path))) {
175 // Another turn can finish before the handover prompt runs (a background task's notice
176 // queued ahead of it). That is not the handover turn: keep waiting for the note.
177 if (now - (S.writingSince || now) < WRITE_TIMEOUT_MS) return
178 await setPhase($, 'idle')
179 S.holdUntil = now + RETRY_AFTER_FAIL_MS
180 $.ui.toast('session-keeper: no handover file was written, so I did not clear. Will retry in 30 min.')
181 return
182 }
183 await setPhase($, 'clearing')
184 try {
185 await $.command.run({ command: 'clear' })
186 } catch (err) {
187 await setPhase($, 'idle')
188 S.holdUntil = now + RETRY_AFTER_FAIL_MS
189 $.ui.toast(`session-keeper: could not run /clear (${String(err)}). The handover is at ${path}`)
190 return
191 }
192 S.lastTouch = await $.clock.now()
193 S.pings = 0
194 S.holdUntil = S.lastTouch + COOLDOWN_AFTER_CLEAR_MS
195 await setPhase($, 'idle')
196 const sent = await $.prompt.submit({ text: resumePrompt(path), asUser: true })
197 if (sent.drop) {
198 $.ui.toast(`session-keeper: cleared, but the resume prompt was not sent (${sent.drop}). Handover: ${path}`)
199 }
200}
201
202// One check a minute. Ping when 58 minutes have passed with no activity; if the
203// cache already lapsed while you were away and the context is big, hand over
204// instead of paying to re-read it all.
205const tick = async ($: EngineInterface) => {
206 await restorePhase($)
207 await refreshView($)
208 const now = await $.clock.now()
209 if (S.phase === 'writing' && !S.isWorking && S.writingSince && now - S.writingSince >= WRITE_TIMEOUT_MS) {
210 return finishHandover($) // the note never came: give up and say so
211 }
212 if (S.phase !== 'idle' || S.isWorking) return
213 const idle = now - S.lastTouch
214
215 if (idle > CACHE_TTL_MS && now >= S.holdUntil && (await isAutoclear($))) {
216 const v = await assess($, S.lastTouch)
217 if (v.clear) return startHandover($, v.why)
218 }
219
220 if (idle >= PING_EVERY_MS && S.pings < MAX_PINGS_IN_A_ROW && !(await isAway($))) {
221 const { context } = await $.session.usage()
222 if ((context.tokens ?? 0) < MIN_TOKENS_TO_PING) return
223 const r = await $.model.fork({ prompt: 'Keep-alive ping. Reply with the single word: ok' })
224 if (r.isAnswered) {
225 S.pings += 1
226 S.lastTouch = await $.clock.now()
227 S.isPingFailureShown = false
228 } else if (r.reason !== 'nothing-to-fork' && !S.isPingFailureShown) {
229 S.isPingFailureShown = true
230 $.ui.toast(`session-keeper: keep-warm ping failed (${r.reason})`)
231 }
232 }
233}
234
235const onTurnComplete = async ($: EngineInterface) => {
236 S.isWorking = false
237 S.lastTouch = await $.clock.now()
238 S.pings = 0
239 await restorePhase($)
240 await refreshView($)
241 if (S.phase === 'writing') {
242 // Let the turn close before clearing under it.
243 $.clock.after(1500, () => void finishHandover($).catch(() => {}))
244 } else if (S.phase === 'idle' && S.lastTouch >= S.holdUntil && (await isAutoclear($))) {
245 const v = await assess($, S.lastTouch)
246 if (v.clear) await startHandover($, v.why)
247 }
248}
249
250export const register: Register = on => {
251 on('turn.start', ($, e, next) => {
252 S.isWorking = true
253 return next(e)
254 })
255
256 on('turn.complete', async ($, e, next) => {
257 const result = await next(e)
258 if (!e.agentId) void onTurnComplete($).catch(() => {}) // a subagent's turn is not the session's
259 return result
260 })
261
262 on('session.start', async ($, e, next) => {
263 const started = await next(e)
264 S.lastTouch = await $.clock.now()
265 $.clock.every(CHECK_EVERY_MS, () => void tick($).catch(() => {}))
266
267 try {
268 await $.command.register({
269 name: 'away',
270 description: 'Away mode: stop the 58-minute keep-warm ping. /away on, /away off, or /away to toggle',
271 })
272 await $.command.register({
273 name: 'autoclear',
274 description: 'Let the session hand over and clear by itself when it gets big. /autoclear on, /autoclear off, or /autoclear to toggle',
275 })
276 await $.command.register({
277 name: 'handover',
278 description: 'Show why this session would or would not clear; /handover now writes a handover and clears',
279 })
280 } catch (err) {
281 $.ui.toast(`session-keeper: commands not registered (${String(err)})`)
282 }
283 return started
284 })
285
286 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
287 // Draw beneath first, so the usage meter's band stays and this row sits with it.
288 const below = await next(e)
289 const v = await read($, view)
290 if (e.props.hasSurvey || !v) return below
291
292 const { Box, Text } = $.ui.resolve(e)
293 const k = (n: number) => `${Math.round(n / 1000)}k`
294 const near = v.tokens >= v.tokenLimit * 0.8 || v.turns >= v.turnLimit * 0.8
295 const row = (
296 <Box columnGap={2} paddingX={1}>
297 <Text dimColor>keeper</Text>
298 <Text color={v.cold ? 'red' : 'green'}>{v.cold ? `cache cold ${v.ageMin}m` : `cache warm ${v.ageMin}m`}</Text>
299 <Text color={v.away ? 'yellow' : undefined} dimColor={!v.away}>
300 {v.away ? 'away: no pings' : v.nextPingMin === null ? 'ping paused' : `ping in ${v.nextPingMin}m`}
301 </Text>
302 <Text color={near ? 'yellow' : undefined} dimColor={!near}>
303 {`${v.autoclear ? 'clears' : 'would clear'} at ${k(v.tokenLimit)} / ${v.turnLimit} turns (now ${k(v.tokens)} / ${v.turns})`}
304 </Text>
305 {!v.autoclear && <Text color="yellow">autoclear off</Text>}
306 {v.isWriting && <Text color="cyan">handover in progress</Text>}
307 </Box>
308 )
309
310 return below ? (
311 <Box flexDirection="column">
312 {below}
313 {row}
314 </Box>
315 ) : (
316 row
317 )
318 })
319
320 on('command.run', { command: 'away' }, async ($, e) => {
321 const arg = e.args.trim().toLowerCase()
322 const now = arg === 'on' ? true : arg === 'off' ? false : !(await isAway($))
323 await $.store.set('away', now)
324 await refreshView($)
325 return {
326 text: now
327 ? 'Away mode ON: no keep-warm pings. The cache will lapse after an hour; a big session may hand over and clear on its own.'
328 : `Away mode OFF: pinging every ${PING_EVERY_MS / MIN} minutes while idle.`,
329 }
330 })
331
332 on('command.run', { command: 'autoclear' }, async ($, e) => {
333 const arg = e.args.trim().toLowerCase()
334 const now = arg === 'on' ? true : arg === 'off' ? false : !(await isAutoclear($))
335 await $.store.set('autoclear', now)
336 await refreshView($)
337 return {
338 text: now
339 ? 'Autoclear ON: a big or cold session hands over and clears by itself.'
340 : 'Autoclear OFF: the session never clears by itself. /handover now still works, and keep-warm pings are unchanged.',
341 }
342 })
343
344 on('command.run', { command: 'handover' }, async ($, e) => {
345 const v = await assess($, S.lastTouch)
346 if (e.args.trim().toLowerCase() === 'now') {
347 if (S.phase !== 'idle' || S.isWorking) return { text: 'Busy: wait for the current turn or handover to finish.' }
348 void startHandover($, 'asked by you')
349 return { text: `Writing a handover, then clearing. ${summary(v)}` }
350 }
351 const away = (await isAway($)) ? 'away mode on' : `pinging every ${PING_EVERY_MS / MIN} min`
352 const auto = (await isAutoclear($)) ? 'autoclear on' : 'autoclear off'
353 return { text: `${summary(v)} (${away}, ${auto}, ${S.pings} ping${S.pings === 1 ? '' : 's'} since the last reply)` }
354 })
355}
356types/index.d.ts 21 lines1export type View = {
2 cold: boolean
3 ageMin: number
4 /** Minutes until the next keep-warm ping; null when away or the ping budget is spent. */
5 nextPingMin: number | null
6 away: boolean
7 /** Whether the session may hand over and clear on its own (/autoclear). */
8 autoclear: boolean
9 tokens: number
10 tokenLimit: number
11 turns: number
12 turnLimit: number
13 isWriting: boolean
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'session-keeper': { view: View | null; phase: string }
19 }
20}
21