SLOPSHOPPER

on-demand-savior

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

newpanebandguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · on-demand-savior
│ ┃ handoff ✕ › fix the failing auth test and add an audit log call │ ┃ No handoff yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /savior │ ⎿ on-demand-savior: on-demand-savior is on: pauses at 5-hour 96% o │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · handoff
No handoff yet.
README

on-demand-savior

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.

  1. Watch: every usage reading is checked against the 5-hour and weekly thresholds (96% by default), or the on-demand budget when one is set.
  2. Pause: the running turn is stopped, and new prompts and tool calls are held back.
  3. Hand off: the model writes a handoff (goal, done so far, in progress, next steps, key files, open questions) to 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.
  4. Resume: a minute after the window resets, the handoff is sent back as a prompt and the work carries on.

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.

Commands

CommandWhat it does
/savior or /savior statusWhether it is on, and any pause in force
/savior offStop guarding (and clear a pause): Claude goes on into on-demand usage
/savior onGuard again, ending an early resume's quiet; pauses at once if the last reading is already past the threshold
/savior resumeResume from the handoff now, before the reset. Limit checks stay off until that window resets, so this may use on-demand usage
/savior handoffShow the handoff in the side pane

On or off is remembered across sessions.

Settings

Set in /config:

SettingDefaultWhat it does
fiveHourThreshold96Pause once the 5-hour limit reaches this percent used
weeklyThreshold96Pause once the weekly limit reaches this percent used
onDemandBudgetUsd0On-demand usage to let through once a limit is used up, then pause
handoffDiremptyFolder 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.

Good to know

  • The pause comes from a usage reading, and one arrives after each model response, so it can land a point or two past the threshold. Turning off extra usage in your claude.ai settings is the hard stop; this mod is the graceful one.
  • Writing the handoff is one more model call, which is what the room below 100% is for.
  • The resume needs Claude Code open. Closed, the next session in the same project finds the pause and resumes from its handoff once the window has reset.
  • A pause belongs to the project it happened in.

Develop

claude plugin validate mods/on-demand-savior
claude plugin test mods/on-demand-savior
Source 2 files
hooks/register.tsx 476 lines
1import { 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}
476
types/index.d.ts 18 lines
1/** 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