Watches chat fill, saves a HANDOFF.md at a threshold, and offers a clean resume.

<img width="1096" height="808" alt="Screenshot 2026-10-19 at 8 25 29 PM" src="https://github.com/user-attachments/assets/b22cc708-5a70-4333-b3ba-79517a3b7603" />
Mods for Claude Code that add a band above the prompt, a side pane or a slash command. Each is its own plugin, so you can install only the ones you want.
| Plugin | Band | What it does | Command | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
band-rule | line | Draws a dim line above the bands to separate them from Claude's output. List it before the other mods (see below). | none | |||||||||
cache-clock | CACHE | Shows whether the prompt cache is still warm, the time left, % reused and the cold-rewrite cost, with a Compact button. Toasts shortly before the cache goes cold. | `/cache-clock on \ | off \ | status` | |||||||
usage-gauge | USAGE | Plan usage windows (5h, week, others) with reset times, the session cost and a pace warning. Toasts at 75% and 90%. | /usage-gauge (opens a pane), `on \ | off \ | warn <a> <b> \ | status` | ||||||
usage-limits | footer | Session (5h) and week (7d) rate-limit bars in the prompt footer. | none | |||||||||
auto-handoff | CONTEXT | Watches how full the chat is and saves a HANDOFF.md when it reaches a threshold, then offers to clear and resume. | `/auto-handoff on \ | off \ | now \ | resume \ | threshold <n> \ | mode auto\ | ask \ | hide \ | show \ | status` |
agent-watch | AGENTS | Shows the main chat and helper agents with their models and tasks, plus the skills that ran this turn. Opens an Agents pane. | /agent-watch (opens the Agents pane), `on \ | off \ | clear \ | help` |
Bands stack in the order their plugins appear in enabledPlugins in ~/.claude/settings.json, first on top. To reorder, move the lines and run /reload-plugins; claude plugin install adds new entries at the end. band-rule draws its line above the whole stack only when it is listed first. The author's order, top to bottom: band-rule, agent-watch, auto-handoff, cache-clock, usage-gauge, usage-limits. The last one draws in the footer, so its place doesn't matter. The Band column is the title each mod shows in its band.
Each band is one row. In a narrow terminal the end of the row is cut off with … instead of wrapping.
usage-gauge and usage-limits both show plan usage, so enable only one.
In auto mode, auto-handoff sends the handoff prompt as soon as the threshold is crossed, even while Claude is mid-task. The default ask mode only offers the button.
cache-clock has a cacheMinutes option (5 or 60, default 60). Other settings are set through each mod's commands and persist between sessions.
Install a mod from the marketplace:
/plugin install cache-clock --marketplace Abdallah-Abdelazim/claude-code-mods
Run it once per mod, changing the plugin name. From a local clone, use --marketplace ~/path/to/claude-code-mods instead.
To try one without installing: claude --plugin-dir plugins/<name>.
These mods use Claude Code's function-hook plugin API, which is in early access; they were written against Claude Code 2.1.303. Where an organization manages Claude Code, plugins you install don't run the session.compact, skill.prompt and prompt.compose hooks.
Each folder under plugins/ is a standalone mod: .claude-plugin/plugin.json, hooks/, tsconfig.json, a *.test.ts and, for a mod that keeps state, types/index.d.ts. See CLAUDE.md for the conventions and the release steps.
claude plugin validate plugins/<name>
claude plugin test plugins/<name>
The engine writes .claude-plugin/types/ inside a mod when it loads it; the folder is git-ignored.
hooks/register.tsx 271 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register, SessionMeasureInput } from 'claude-code'
3
4import type { HandoffSettings } from '../types'
5import { FILE, RESUME_PROMPT, agoLong, agoShort, TRACK, bar, clockTime, firstObjective, handoffPrompt, toneOf } from './format'
6
7const KEY = 'auto-handoff'
8const FRESH_MS = 10 * 60_000
9const SAVE_TIMEOUT_MS = 10 * 60_000
10const DEFAULTS: HandoffSettings = { isOn: true, threshold: 80, mode: 'ask', isHidden: false }
11const USAGE = 'Usage: /auto-handoff on | off | now | resume | threshold <n> | mode auto|ask | hide | show | status'
12
13const settingsA = atom({ plugin: 'auto-handoff', key: 'settings' } as const, DEFAULTS)
14const fillA = atom({ plugin: 'auto-handoff', key: 'fill' } as const, null)
15const phaseA = atom({ plugin: 'auto-handoff', key: 'phase' } as const, 'idle')
16const savingSinceA = atom({ plugin: 'auto-handoff', key: 'savingSince' } as const, null)
17const savedAtA = atom({ plugin: 'auto-handoff', key: 'savedAt' } as const, null)
18const warnedA = atom({ plugin: 'auto-handoff', key: 'warned' } as const, false)
19const staleA = atom({ plugin: 'auto-handoff', key: 'stale' } as const, null)
20const seenMtimeA = atom({ plugin: 'auto-handoff', key: 'seenMtime' } as const, null)
21
22async function loadSettings($: Engine) {
23 const raw = await $.store.get(KEY)
24 await update($, settingsA, () => ({ ...DEFAULTS, ...((raw ?? {}) as Partial<HandoffSettings>) }))
25}
26
27async function saveSettings($: Engine, next: HandoffSettings) {
28 await $.store.set(KEY, next)
29 await update($, settingsA, () => next)
30}
31
32async function handoffOf($: Engine, now: number) {
33 const file = `${await $.session.cwd()}/${FILE}`
34 if (!(await $.fs.exists(file))) return null
35 const { mtimeMs } = await $.fs.stat(file)
36 return { file, mtimeMs, isFresh: now - mtimeMs <= FRESH_MS }
37}
38
39async function commandNames($: Engine) {
40 return (await $.command.list()).map(c => c.name)
41}
42
43async function start($: Engine) {
44 await loadSettings($)
45 const now = await $.clock.now()
46 const usage = await $.session.usage().catch(() => null)
47 await update($, fillA, () => usage?.context.percent ?? null)
48
49 const handoff = await handoffOf($, now)
50 if (handoff && handoff.mtimeMs !== (await read($, seenMtimeA))) {
51 await update($, seenMtimeA, () => handoff.mtimeMs)
52 const objective = firstObjective(await $.fs.read(handoff.file))
53 await update($, staleA, () => ({ objective, mtimeMs: handoff.mtimeMs }))
54 $.ui.toast(`Handoff from ${agoLong(now - handoff.mtimeMs)} available — /auto-handoff resume`)
55 }
56}
57
58async function onMeasure($: Engine, e: SessionMeasureInput) {
59 const fill = e.context.percent ?? null
60 await update($, fillA, () => fill)
61 if (fill === null) return
62
63 const { threshold, mode, isOn } = await read($, settingsA)
64 if (!isOn) return
65 if (fill < threshold) {
66 await update($, warnedA, () => false)
67 if ((await read($, phaseA)) === 'due') await update($, phaseA, () => 'idle')
68 return
69 }
70 if (await read($, warnedA)) return
71
72 await update($, warnedA, () => true)
73 $.ui.toast(`Chat is ${threshold}% full — saving a handoff`)
74 if (mode === 'auto') $.clock.after(0, () => runSave($))
75 else await update($, phaseA, () => 'due')
76}
77
78// Called from a timer: commands and prompts never run inside a hook.
79async function runSave($: Engine) {
80 const now = await $.clock.now()
81 await update($, phaseA, () => 'saving')
82 await update($, savingSinceA, () => now)
83 if ((await commandNames($)).includes('handoff')) {
84 await $.command.run({ command: 'handoff' })
85 } else {
86 await $.prompt.submit({ text: handoffPrompt(`${await $.session.cwd()}/${FILE}`) })
87 }
88}
89
90async function checkSaved($: Engine) {
91 if ((await read($, phaseA)) !== 'saving') return
92 const since = await read($, savingSinceA)
93 const now = await $.clock.now()
94 const handoff = await handoffOf($, now)
95 if (since === null || !handoff || handoff.mtimeMs < since) {
96 // An interrupted turn or a handoff written elsewhere would otherwise leave the band stuck on "saving".
97 if (since === null || now - since > SAVE_TIMEOUT_MS) await update($, phaseA, () => 'idle')
98 return
99 }
100
101 await update($, phaseA, () => 'saved')
102 await update($, savedAtA, () => handoff.mtimeMs)
103 await update($, staleA, () => null)
104 await update($, seenMtimeA, () => handoff.mtimeMs)
105}
106
107async function runResume($: Engine) {
108 if ((await commandNames($)).includes('handoff')) {
109 await $.command.run({ command: 'handoff', args: 'resume' })
110 } else {
111 await $.prompt.submit({ text: RESUME_PROMPT })
112 }
113 await update($, staleA, () => null)
114}
115
116async function runClear($: Engine) {
117 const cleared = await $.command.run({ command: 'clear' }).then(() => true, () => false)
118 if (!cleared) {
119 $.ui.toast('Could not run /clear. Type /clear, then /auto-handoff resume.')
120 return
121 }
122 await update($, phaseA, () => 'idle')
123 await runResume($)
124}
125
126async function summary($: Engine) {
127 const s = await read($, settingsA)
128 const fill = await read($, fillA)
129 const phase = await read($, phaseA)
130 const savedAt = await read($, savedAtA)
131 const stale = await read($, staleA)
132 const now = await $.clock.now()
133 const lines = [
134 `${s.isOn ? 'on' : 'off'}${s.isHidden ? ', hidden' : ''} · threshold ${s.threshold}% · mode ${s.mode}`,
135 `context ${fill === null ? 'unknown' : `${Math.round(fill)}%`} · ${phase}`,
136 ]
137 if (savedAt !== null) lines.push(`last handoff ${clockTime(savedAt)}`)
138 if (stale) lines.push(`from ${agoLong(now - stale.mtimeMs)}: ${stale.objective}`)
139 return lines.join('\n')
140}
141
142export const register: Register = on => {
143 on('session.start', async ($, e, next) => {
144 await $.command.register({
145 name: 'auto-handoff',
146 description: 'Watch chat fill and save a handoff at a threshold.',
147 argumentHint: 'on | off | now | resume | threshold <n> | mode auto|ask | hide | show | status',
148 })
149 await start($)
150
151 return next(e)
152 })
153
154 on('session.measure', async ($, e, next) => {
155 await onMeasure($, e)
156
157 return next(e)
158 })
159
160 on('turn.complete', async ($, e, next) => {
161 await checkSaved($)
162
163 return next(e)
164 })
165
166 on('session.compact', async ($, e, next) => {
167 if (e.trigger !== 'precompute' && e.agentId === undefined) {
168 const handoff = await handoffOf($, await $.clock.now())
169 if (!handoff?.isFresh) $.ui.toast('Compacting without a fresh handoff')
170 }
171
172 return next(e)
173 }).catch(($, e, next) => next(e))
174
175 on('session.end', async ($, e, next) => {
176 if (e.reason === 'clear') await update($, warnedA, () => false)
177
178 return next(e)
179 })
180
181 on('command.run', { command: 'auto-handoff' }, async ($, e) => {
182 const [cmd = '', arg = ''] = e.args.trim().split(/\s+/)
183 const s = await read($, settingsA)
184
185 if (cmd === 'on' || cmd === 'off') {
186 await saveSettings($, { ...s, isOn: cmd === 'on' })
187 return { text: `Auto-handoff ${cmd}.` }
188 }
189 if (cmd === 'hide' || cmd === 'show') {
190 await saveSettings($, { ...s, isHidden: cmd === 'hide' })
191 return { text: `Auto-handoff band ${cmd === 'hide' ? 'hidden' : 'shown'}.` }
192 }
193 if (cmd === 'mode') {
194 if (arg !== 'auto' && arg !== 'ask') return { text: USAGE }
195 await saveSettings($, { ...s, mode: arg })
196 return { text: `Auto-handoff mode ${arg}.` }
197 }
198 if (cmd === 'threshold') {
199 const n = Number(arg)
200 if (!Number.isInteger(n) || n < 1 || n > 100) return { text: 'Threshold must be a whole number, 1 to 100.' }
201 await saveSettings($, { ...s, threshold: n })
202 return { text: `Auto-handoff threshold ${n}%.` }
203 }
204 if (cmd === 'now') {
205 $.clock.after(0, () => runSave($))
206 return { text: 'Saving a handoff.' }
207 }
208 if (cmd === 'resume') {
209 $.clock.after(0, () => runResume($))
210 return { text: 'Resuming from HANDOFF.md.' }
211 }
212 if (cmd === 'status') return { text: await summary($) }
213
214 return { text: USAGE }
215 })
216
217 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
218 const below = await next(e)
219 const s = await read($, settingsA)
220 if (!s.isOn || s.isHidden) return below
221
222 const { Box, Button, Text } = $.ui.resolve(e)
223 const fill = await read($, fillA)
224 const phase = await read($, phaseA)
225 const savedAt = await read($, savedAtA)
226 const stale = await read($, staleA)
227 const now = await $.clock.now()
228 const isDue = fill !== null && fill >= s.threshold
229 const cells = fill === null ? null : bar(fill, 6)
230 const showStale = stale !== null && phase === 'idle' && !isDue
231
232 return (
233 <Box flexDirection="column">
234 <Box>
235 <Box flexShrink={0}><Text bold color="#0b0b0f" backgroundColor="#7aa2f7"> ⇄ CONTEXT </Text></Box>
236 <Text wrap="truncate-end">
237 <Text color={fill === null ? '#a1a1aa' : toneOf(fill, s.threshold)}>
238 {' ctx '}
239 {cells ? cells.on : '—'}
240 {cells && <Text color={TRACK}>{cells.off}</Text>}
241 {' '}
242 {fill === null ? '' : `${Math.round(fill)}%`}
243 </Text>
244 <Text color={isDue ? '#ef4444' : undefined} dimColor={!isDue}>
245 {' · saves at '}
246 {s.threshold}%
247 </Text>
248 {phase === 'saving' && <Text dimColor>{' · saving…'}</Text>}
249 {phase === 'saved' && savedAt !== null && (
250 <Text color="#22c55e">{' ✓ saved '}{clockTime(savedAt)}</Text>
251 )}
252 {showStale && (
253 <Text dimColor>{' from '}{agoShort(now - stale.mtimeMs)}{' ago: '}{stale.objective}</Text>
254 )}
255 </Text>
256 {phase === 'saved' && (
257 <Box marginLeft={1} flexShrink={0}><Button key="clear" plain label="[clear & continue]" onPress={() => $.clock.after(0, () => runClear($))} /></Box>
258 )}
259 {showStale && (
260 <Box marginLeft={1} flexShrink={0}><Button key="resume" plain label="[resume]" onPress={() => $.clock.after(0, () => runResume($))} /></Box>
261 )}
262 {phase !== 'saving' && phase !== 'saved' && !showStale && (
263 <Box marginLeft={1} flexShrink={0}><Button key="save" plain label="[save now]" onPress={() => $.clock.after(0, () => runSave($))} /></Box>
264 )}
265 </Box>
266 {below}
267 </Box>
268 )
269 })
270}
271hooks/format.ts 63 lines1export const FILE = 'HANDOFF.md'
2export const RESUME_PROMPT = 'Read HANDOFF.md in this folder and continue from its exact next step.'
3
4export const handoffPrompt = (path: string) =>
5 [
6 `Write the handoff file ${path} now, overwriting it, with these sections in this order:`,
7 '# Handoff',
8 '## Objective',
9 'One line: what this chat is for.',
10 '## Done',
11 '## In progress',
12 '## Next',
13 '## Decisions',
14 'Each decision, and why.',
15 '## Open questions',
16 '## Exact next step',
17 'One sentence a fresh session can act on.',
18 'Write only the file. Do not continue the task.',
19 ].join('\n')
20
21// Green under 60%, amber from 60 up to the threshold, red at or over it.
22export const toneOf = (fill: number, threshold: number) =>
23 fill >= threshold ? '#ef4444' : fill >= 60 ? '#f59e0b' : '#22c55e'
24
25// The empty part is the same block in TRACK gray, drawn as its own Text run.
26export const TRACK = '#52525b'
27
28export function bar(pct: number, cells: number) {
29 const on = Math.max(0, Math.min(cells, Math.round((cells * pct) / 100)))
30 return { on: '▆'.repeat(on), off: '▆'.repeat(cells - on) }
31}
32
33export function clockTime(ms: number) {
34 const d = new Date(ms)
35 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
36}
37
38const minutesOf = (ms: number) => Math.max(0, Math.floor(ms / 60_000))
39
40export function agoShort(ms: number) {
41 const m = minutesOf(ms)
42 if (m >= 1440) return `${Math.floor(m / 1440)}d`
43 if (m >= 60) return `${Math.floor(m / 60)}h`
44 return `${m}m`
45}
46
47export function agoLong(ms: number) {
48 const m = minutesOf(ms)
49 const unit = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'} ago`
50 if (m >= 1440) return unit(Math.floor(m / 1440), 'day')
51 if (m >= 60) return unit(Math.floor(m / 60), 'hour')
52 return unit(m, 'minute')
53}
54
55// The line under "## Objective"; for a file written some other way, the first prose line.
56export function firstObjective(text: string) {
57 const lines = text.split('\n').map(l => l.trim())
58 const at = lines.findIndex(l => /^##\s*objective/i.test(l))
59 const body = at >= 0 ? lines.slice(at + 1) : lines
60 const line = body.find(l => l !== '' && !l.startsWith('#')) ?? 'no objective'
61 return line.length > 60 ? `${line.slice(0, 59)}…` : line
62}
63types/index.d.ts 19 lines1export type HandoffSettings = { isOn: boolean; threshold: number; mode: 'auto' | 'ask'; isHidden: boolean }
2export type HandoffPhase = 'idle' | 'due' | 'saving' | 'saved'
3export type HandoffStale = { objective: string; mtimeMs: number }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'auto-handoff': {
8 settings: HandoffSettings
9 fill: number | null
10 phase: HandoffPhase
11 savingSince: number | null
12 savedAt: number | null
13 warned: boolean
14 stale: HandoffStale | null
15 seenMtime: number | null
16 }
17 }
18}
19