Pauses Claude before the 5-hour or weekly limit spills into on-demand usage, writes a handoff, and resumes from it once the limit resets

Stops Claude before your 5-hour or weekly limit spills into on-demand (extra) usage, then picks the work back up once the limit resets.
handoff-<project>-<time>.md, in the project's .claude/handoffs or the folder you choose. Handoffs are kept: each pause writes a new file, and nothing deletes one.While it waits, the band right above the prompt shows 🛟 Paused at 5-hour 97% · resumes 3:31 PM with a Resume now button (r once the band has focus) to go on now.
The handoff opens in a side pane on the right, with the same button. A pane opened on its own needs a terminal at least 144 columns wide; /savior handoff opens it at any width.
| Command | What it does |
|---|---|
/savior or /savior status | Whether it is on, and any pause in force |
/savior off | Stop guarding (and clear a pause): Claude goes on into on-demand usage |
/savior on | Guard again, ending an early resume's quiet; pauses at once if the last reading is already past the threshold |
/savior resume | Resume from the handoff now, before the reset. Limit checks stay off until that window resets, so this may use on-demand usage |
/savior handoff | Show the handoff in the side pane |
On or off is remembered across sessions.
Set in /config:
| Setting | Default | What it does |
|---|---|---|
fiveHourThreshold | 96 | Pause once the 5-hour limit reaches this percent used |
weeklyThreshold | 96 | Pause once the weekly limit reaches this percent used |
onDemandBudgetUsd | 0 | On-demand usage to let through once a limit is used up, then pause |
handoffDir | empty | Folder to keep handoffs in: absolute (D:/notes/handoffs), under your home (~/handoffs), or relative to the project (docs/handoffs). Empty uses the project's .claude/handoffs |
With a budget above 0, the thresholds step aside: the limit runs full, on-demand usage is counted from that moment (at API prices, as /cost counts it, within this session), and the pause comes once the budget is spent.
claude plugin validate mods/on-demand-savior
claude plugin test mods/on-demand-saviorhooks/register.tsx 476 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage, SessionRateLimit, Timer } from 'claude-code'
3
4import type { Pause, Settings, Spend, Trip } from '../types'
5
6const COMMAND = 'savior'
7// The pause in force, mirrored from the store for what the band draws.
8const pauseState = atom({ plugin: 'on-demand-savior', key: 'pause' } as const, null)
9// The handoff's text, for the side pane.
10const handoffState = atom({ plugin: 'on-demand-savior', key: 'handoff' } as const, null)
11const PANE = 'handoff'
12const WATCHED = ['five_hour', 'seven_day']
13const LABELS: Record<string, string> = { five_hour: '5-hour', seven_day: 'weekly' }
14const DEFAULT_THRESHOLD = 96
15// Resume a minute after the reset, so the first request lands in the new window.
16const GRACE_MS = 60_000
17const POLL_MS = 30_000
18const DAY = 24 * 60 * 60 * 1000
19
20export const label = (kind: string) => LABELS[kind] ?? kind.replace(/_/g, ' ')
21
22export const usd = (amount: number) => `$${amount.toFixed(2)}`
23
24export const settings = (options: Record<string, unknown>): Settings => {
25 const percent = (setting: unknown) => {
26 const n = Number(setting)
27
28 return Number.isFinite(n) && n > 0 && n <= 100 ? n : DEFAULT_THRESHOLD
29 }
30 const budget = Number(options.onDemandBudgetUsd)
31
32 return {
33 fiveHour: percent(options.fiveHourThreshold),
34 weekly: percent(options.weeklyThreshold),
35 budgetUsd: Number.isFinite(budget) && budget > 0 ? budget : 0,
36 handoffDir: typeof options.handoffDir === 'string' ? options.handoffDir.trim() : '',
37 }
38}
39
40// Where a handoff is saved: the setting's folder (`~` the home folder, a relative
41// one under the project), else the project's .claude/handoffs. Named by project
42// and time, so a shared folder keeps every one apart and nothing is overwritten.
43export const handoffFile = (dir: string, cwd: string, home: string | undefined, now: number) => {
44 const trim = (path: string) => path.replace(/[\\/]+$/, '')
45 const root = trim(cwd)
46 const folder =
47 dir === ''
48 ? `${root}/.claude/handoffs`
49 : /^~([\\/]|$)/.test(dir) && home
50 ? trim(home) + dir.slice(1)
51 : /^([a-zA-Z]:)?[\\/]/.test(dir)
52 ? dir
53 : `${root}/${dir}`
54 const project = root.split(/[\\/]/).pop() || 'project'
55
56 return `${trim(folder)}/handoff-${project}-${stamp(now)}.md`
57}
58
59// The watched windows of a reading, a window whose reset has passed being stale.
60const live = (limits: readonly SessionRateLimit[], now: number) =>
61 limits.filter(l => WATCHED.includes(l.kind) && l.resetsAt && Date.parse(l.resetsAt) > now)
62
63// Of several, the window that resets last: the one to wait out.
64const latest = (limits: readonly SessionRateLimit[]) =>
65 [...limits].sort((a, b) => Date.parse(b.resetsAt!) - Date.parse(a.resetsAt!))[0]
66
67// A window at or past its own threshold.
68export const tripped = (limits: readonly SessionRateLimit[], s: Settings, now: number): Trip | undefined => {
69 const hit = latest(live(limits, now).filter(l => l.percentUsed >= (l.kind === 'five_hour' ? s.fiveHour : s.weekly)))
70
71 return hit && { kind: hit.kind, resetsAt: hit.resetsAt!, reason: `${label(hit.kind)} ${hit.percentUsed}%` }
72}
73
74// A window used up: past it, requests are billed as on-demand usage.
75export const spilled = (limits: readonly SessionRateLimit[], now: number) =>
76 latest(live(limits, now).filter(l => l.percentUsed >= 100))
77
78// The budget spent since the window read full; undefined while there is room.
79export const overBudget = (full: SessionRateLimit, spend: Spend, costUsd: number, budgetUsd: number): Trip | undefined => {
80 const spent = costUsd - spend.baseUsd
81
82 return spent >= budgetUsd ? { kind: full.kind, resetsAt: full.resetsAt!, reason: `${usd(spent)} of on-demand` } : undefined
83}
84
85export const resumesAt = (pause: Pause) => Date.parse(pause.resetsAt) + GRACE_MS
86
87// "3:31 PM", or "Sat 3:31 PM" when more than a day out.
88export const clockTime = (at: number, now: number) =>
89 new Intl.DateTimeFormat('en-US', {
90 weekday: at - now > DAY ? 'short' : undefined,
91 hour: 'numeric',
92 minute: '2-digit',
93 }).format(at)
94
95// 2026-10-09T14:30:05.000Z -> "2026-10-09T14-30-05"
96export const stamp = (now: number) => new Date(now).toISOString().slice(0, 19).replace(/:/g, '-')
97
98export const handoffPrompt = (pause: Pause) =>
99 [
100 `You are being paused to keep usage in bounds (${pause.reason}; the ${label(pause.kind)} limit).`,
101 'Write a handoff document that lets you pick this work up after the limit resets, with no other memory of this conversation.',
102 'Use Markdown with these sections: Goal; Done so far; In progress (exactly where you stopped, including any half-finished edit);',
103 'Next steps (in order); Key files and facts (paths, commands, decisions, gotchas); Open questions for the user.',
104 'Be specific and concise. Reply with the document only.',
105 ].join(' ')
106
107// Used when the model could not write the handoff: the conversation's tail, as it stands.
108export const transcriptHandoff = (messages: readonly SessionMessage[]) =>
109 [
110 '# Handoff (transcript excerpt)',
111 'The model could not write a summary, so this is the end of the conversation as it stood.',
112 ...messages.slice(-20).map(m => {
113 const tools = m.toolUses.length ? `\n_tools: ${m.toolUses.map(u => u.tool).join(', ')}_` : ''
114
115 return `**${m.role}**: ${m.text.slice(0, 2000)}${tools}`
116 }),
117 ].join('\n\n')
118
119export const resumePrompt = (path: string | undefined, doc: string | undefined) =>
120 doc
121 ? `The usage limit has reset. on-demand-savior paused you before it ran into on-demand usage and saved this handoff to ${path}. Pick the work up from it:\n\n${doc}`
122 : 'The usage limit has reset. on-demand-savior paused you before it ran into on-demand usage. Pick the work up where you left off.'
123
124const pausedLine = (pause: Pause, now: number) =>
125 `🛟 Paused at ${pause.reason} · resumes ${clockTime(resumesAt(pause), now)}`
126
127// Module variables: a reload starts them over, the store keeps the pause itself.
128let config = settings({})
129let turnId: string | undefined
130let poll: Timer | undefined
131let isPausing = false
132
133// One pause per project, so a session elsewhere neither resumes nor clears it.
134async function key($: EngineInterface) {
135 return `pause:${await $.session.cwd()}`
136}
137
138async function quietKey($: EngineInterface) {
139 return `quiet:${await $.session.cwd()}`
140}
141
142// Until when an early resume keeps the checks quiet; 0 when it does not.
143async function quietUntil($: EngineInterface) {
144 return Number((await $.store.get(await quietKey($))) ?? 0)
145}
146
147async function isEnabled($: EngineInterface) {
148 return (await $.store.get('enabled')) !== false
149}
150
151async function getPause($: EngineInterface) {
152 return (await $.store.get(await key($))) as Pause | undefined
153}
154
155// Keeps the pause in the store (across sessions) and in state (for the band).
156async function savePause($: EngineInterface, pause: Pause | undefined) {
157 if (pause) {
158 await $.store.set(await key($), pause)
159 } else {
160 await $.store.delete(await key($))
161 }
162 await update($, pauseState, () => pause ?? null)
163}
164
165// Opens the side pane on the handoff; unasked it waits for a wide enough terminal.
166async function showHandoff($: EngineInterface, doc: string) {
167 await update($, handoffState, () => doc)
168 await $.ui.open({ id: PANE, title: 'Handoff' }).catch(() => undefined)
169}
170
171async function closeHandoff($: EngineInterface) {
172 await update($, handoffState, () => null)
173 await $.ui.close({ id: PANE }).catch(() => undefined)
174}
175
176// The pause in force: enabled, and its window not yet reset.
177async function active($: EngineInterface) {
178 const pause = await getPause($)
179 if (!pause || !(await isEnabled($))) {
180 return undefined
181 }
182
183 return (await $.clock.now()) < resumesAt(pause) ? pause : undefined
184}
185
186function stopPoll() {
187 poll?.cancel()
188 poll = undefined
189}
190
191// A resume before the reset ('early', the person's choice) also quiets the
192// checks until that window resets: its readings stay past the threshold.
193async function resume($: EngineInterface, isEarly = false) {
194 const pause = await getPause($)
195 stopPoll()
196 if (!pause) {
197 return
198 }
199 if (isEarly) {
200 await $.store.set(await quietKey($), resumesAt(pause))
201 }
202 await savePause($, undefined)
203 await closeHandoff($)
204 const doc = pause.handoffPath ? await $.fs.read(pause.handoffPath).catch(() => undefined) : undefined
205 // Off this dispatch: a command.run hook may not wait on a turn of its own.
206 $.clock.after(0, () => void $.prompt.submit({ text: resumePrompt(pause.handoffPath, doc) }))
207}
208
209function arm($: EngineInterface) {
210 if (poll) {
211 return
212 }
213 poll = $.clock.every(POLL_MS, async () => {
214 const pause = await getPause($)
215 if (!pause || !(await isEnabled($))) {
216 stopPoll()
217 } else if ((await $.clock.now()) >= resumesAt(pause)) {
218 await resume($)
219 }
220 })
221}
222
223async function pauseSession($: EngineInterface, trip: Trip) {
224 if (isPausing || (await getPause($))) {
225 return
226 }
227 isPausing = true
228 try {
229 const now = await $.clock.now()
230 const paused: Pause = { ...trip, pausedAt: now }
231 await savePause($, paused)
232 if (turnId) {
233 await $.turn.abort({ turnId }).catch(() => undefined)
234 }
235
236 const reply = await $.model.fork({ prompt: handoffPrompt(paused) })
237 const doc = reply.isAnswered ? reply.text : transcriptHandoff(await $.session.messages().catch(() => []))
238 const home = config.handoffDir.startsWith('~')
239 ? ((await $.env.get('USERPROFILE').catch(() => undefined)) ?? (await $.env.get('HOME').catch(() => undefined)))
240 : undefined
241 const handoffPath = handoffFile(config.handoffDir, await $.session.cwd(), home, now)
242 await $.fs.write(handoffPath, doc)
243
244 // `/savior off` or `resume` while the handoff was being written wins.
245 if (!(await getPause($))) {
246 return
247 }
248 await savePause($, { ...paused, handoffPath })
249 arm($)
250 await showHandoff($, doc)
251 } finally {
252 isPausing = false
253 }
254}
255
256// With a budget, the windows run full and on into on-demand usage, counted
257// from the session's cost when one first read full until the budget is spent.
258async function spentTrip($: EngineInterface, limits: readonly SessionRateLimit[], costUsd: number | undefined, now: number) {
259 const full = spilled(limits, now)
260 if (!full || costUsd === undefined) {
261 return undefined
262 }
263 const { startedAt } = await $.session.usage()
264 const spendKey = `spend:${await $.session.cwd()}`
265 const spend = (await $.store.get(spendKey)) as Spend | undefined
266 if (!spend || spend.resetsAt !== full.resetsAt || spend.startedAt !== startedAt) {
267 await $.store.set(spendKey, { resetsAt: full.resetsAt!, startedAt, baseUsd: costUsd })
268
269 return undefined
270 }
271
272 return overBudget(full, spend, costUsd, config.budgetUsd)
273}
274
275async function check($: EngineInterface, limits: readonly SessionRateLimit[], costUsd: number | undefined) {
276 if (isPausing || !(await isEnabled($)) || (await getPause($))) {
277 return
278 }
279 const now = await $.clock.now()
280 if (now < (await quietUntil($))) {
281 return
282 }
283 const hit = config.budgetUsd > 0 ? await spentTrip($, limits, costUsd, now) : tripped(limits, config, now)
284 if (hit) {
285 // Off this dispatch: the pause aborts the turn and waits on the model.
286 $.clock.after(0, () => void pauseSession($, hit))
287 }
288}
289
290async function status($: EngineInterface) {
291 if (!(await isEnabled($))) {
292 return 'on-demand-savior is off: at the limit, Claude goes on into on-demand usage. /savior on to guard again.'
293 }
294 const pause = await active($)
295 if (pause) {
296 const handoff = pause.handoffPath ? ` Handoff: ${pause.handoffPath}.` : ''
297
298 return `${pausedLine(pause, await $.clock.now())}.${handoff} /savior resume to go on now (may use on-demand usage).`
299 }
300
301 const now = await $.clock.now()
302 const quiet = await quietUntil($)
303 if (now < quiet) {
304 return `on-demand-savior is on, but resumed early: limit checks are off until ${clockTime(quiet, now)}, when the window resets. /savior on to check again now.`
305 }
306 const { fiveHour, weekly, budgetUsd } = config
307
308 return budgetUsd > 0
309 ? `on-demand-savior is on: lets ${usd(budgetUsd)} of on-demand usage through once a limit is used up, then pauses.`
310 : `on-demand-savior is on: pauses at 5-hour ${fiveHour}% or weekly ${weekly}%.`
311}
312
313export const register: Register = (on, options) => {
314 config = settings(options)
315
316 on('session.start', async ($, e, next) => {
317 const result = await next(e)
318 // The band above the prompt says it all; clear a status line an older version left.
319 $.ui.status(undefined)
320 await $.command.register({
321 name: COMMAND,
322 description: 'Guard against on-demand usage: on, off, status, resume now, or show the handoff',
323 argumentHint: '[on|off|status|resume|handoff]',
324 immediate: true,
325 })
326 // A pause from an earlier session in this project: wait it out, or resume
327 // from its handoff right away if the window already reset.
328 const pause = await getPause($)
329 if (pause && (await isEnabled($))) {
330 await update($, pauseState, () => pause)
331 arm($)
332 const doc = pause.handoffPath ? await $.fs.read(pause.handoffPath).catch(() => undefined) : undefined
333 if (doc) {
334 await showHandoff($, doc)
335 }
336 }
337
338 return result
339 })
340
341 on('command.run', { command: COMMAND }, async ($, e) => {
342 const action = e.args.trim().toLowerCase()
343 if (action === 'off') {
344 await $.store.set('enabled', false)
345 await savePause($, undefined)
346 await closeHandoff($)
347 stopPoll()
348 } else if (action === 'on') {
349 await $.store.set('enabled', true)
350 await $.store.delete(await quietKey($))
351 // The last reading may already be past the threshold; no new point may come.
352 const usage = await $.session.usage()
353 await check($, usage.rateLimits, usage.cost?.usd)
354 } else if (action === 'resume') {
355 if (!(await getPause($))) {
356 return { text: 'on-demand-savior: nothing is paused.' }
357 }
358 await resume($, true)
359
360 return { text: 'on-demand-savior: resumed from the handoff before the reset; limit checks are off until it, so this may use on-demand usage.' }
361 } else if (action === 'handoff') {
362 const pause = await getPause($)
363 const doc = pause?.handoffPath ? await $.fs.read(pause.handoffPath).catch(() => undefined) : undefined
364 if (!doc) {
365 return { text: 'on-demand-savior: no handoff to show.' }
366 }
367 await showHandoff($, doc)
368
369 return { text: `on-demand-savior: showing the handoff from ${pause!.handoffPath}.` }
370 } else if (action !== '' && action !== 'status') {
371 return { text: `on-demand-savior: unknown "${action}". Use /savior on, off, status, resume or handoff.` }
372 }
373
374 return { text: await status($) }
375 })
376
377 on('session.measure', async ($, e, next) => {
378 if (e.changed.includes('rateLimits') || e.changed.includes('cost')) {
379 await check($, e.rateLimits, e.cost?.usd)
380 }
381
382 return next(e)
383 })
384
385 on('turn.start', async ($, e, next) => {
386 turnId = e.turnId
387 const result = await next(e)
388 // A turn that starts while paused (a continuation, a skill) ends at once.
389 if (await active($)) {
390 void $.turn.abort({ turnId: e.turnId }).catch(() => undefined)
391 }
392
393 return result
394 })
395
396 on('turn.complete', async ($, e, next) => {
397 if (!e.agentId && e.turnId === turnId) {
398 turnId = undefined
399 }
400
401 return next(e)
402 })
403
404 // Slash commands pass, so /savior itself still answers while paused.
405 on('prompt.submit', async ($, e, next) => {
406 const pause = e.text.trimStart().startsWith('/') ? undefined : await active($)
407 if (pause) {
408 return { drop: `${pausedLine(pause, await $.clock.now())}. /savior resume to go on now (may use on-demand usage), /savior off to stop guarding.` }
409 }
410
411 return next(e)
412 })
413
414 on('tool.call', async ($, e, next) => {
415 if (await active($)) {
416 return { deny: 'on-demand-savior paused this session at the usage limit; stop here, the work resumes when the limit resets.' }
417 }
418
419 return next(e)
420 })
421
422 // The pause, with the button that ends it, right above the prompt.
423 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
424 const pause = await read($, pauseState)
425 if (!pause || e.props.hasSurvey) {
426 return next(e)
427 }
428 const below = await next(e)
429 const now = await $.clock.now()
430 const { Box, Button, Text } = $.ui.resolve(e)
431
432 return (
433 <Box flexDirection="column">
434 <Box key="savior" flexDirection="row" flexWrap="wrap">
435 <Text color="yellow">{pausedLine(pause, now)} </Text>
436 <Button
437 key="resume"
438 label="Resume now"
439 hotkey="r"
440 variant="primary"
441 onPress={() => resume($, true)}
442 />
443 </Box>
444 {below}
445 </Box>
446 )
447 })
448
449 // The side pane: the handoff, and the same button.
450 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
451 const doc = await read($, handoffState)
452 const pause = await read($, pauseState)
453 const now = await $.clock.now()
454 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
455
456 return (
457 // Two cells either side (about 16px) keep the text off the frame.
458 <Box key="pane" flexDirection="column" paddingX={2}>
459 {pause && (
460 <Box key="paused" flexDirection="row" flexWrap="wrap">
461 <Text color="yellow">{pausedLine(pause, now)} </Text>
462 <Button
463 key="resume"
464 label="Resume now"
465 hotkey="r"
466 variant="primary"
467 onPress={() => resume($, true)}
468 />
469 </Box>
470 )}
471 <Markdown key="handoff" text={doc ?? '_No handoff yet._'} />
472 </Box>
473 )
474 })
475}
476types/index.d.ts 18 lines1/** What trips a pause: the window to wait out, and why, as the status line says it. */
2export type Trip = { kind: string; resetsAt: string; reason: string }
3
4/** A pause in one project: what tripped it and the handoff written for it. */
5export type Pause = Trip & { pausedAt: number; handoffPath?: string }
6
7/** The settings: each window's threshold, the on-demand budget (0: none) and the handoff folder ('' : the project's). */
8export type Settings = { fiveHour: number; weekly: number; budgetUsd: number; handoffDir: string }
9
10/** On-demand spend so far: the session's cost when a window first read full. */
11export type Spend = { resetsAt: string; startedAt: number; baseUsd: number }
12
13declare module 'claude-code' {
14 interface PluginState {
15 'on-demand-savior': { pause: Pause | null; handoff: string | null }
16 }
17}
18