SLOPSHOPPER

clear-resume

Write a handover when a Claude Code session gets long, type /clear, and the next session picks up where you left off with nothing to paste.

newguardcommandtoaststatusprocess
★ 1v0.4.0MITupdated 2026-10-09m4cd4r4/clear-resume/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clear-resume
› fix the failing auth test and add an audit log call ⏺ 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 › /relay ⎿ clear-resume: relay: off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

clear-resume

Clear the chat. Keep the goal, the next step and the decisions.

Before you /clear a long Claude Code chat, type /clear-resume:handover. Claude writes a short note about the work, called a handover: the goal, the next action, where things stand, the decisions made and what already failed. The plugin saves it. After /clear, the fresh session starts with the handover already loaded, so you type go and Claude carries on. Nothing to paste.

In one measured run, Claude built a site across 15 sessions with 91.0M tokens. The same work as one long chat, with no clears, comes to about 565.6M tokens (an estimate, and an upper bound).

Use it

  1. /clear-resume:handover: Claude reads your branch, your changes and your last few commits, then writes and saves the handover.
  2. /clear: the fresh session loads it.
  3. go: Claude carries on from it.

Two optional settings, both off by default:

  • Nudge (auto_nudge): when the chat's context passes Nudge at (180k tokens by default), Claude is asked once per session to save a handover.
  • Relay (relay): after Claude saves a handover, the plugin runs /clear and submits the prompt that continues from it, up to the number of clears you allow. It stops when the budget is used or when two continued sessions in a row make no new commit. Needs Claude Code 2.1.275 or later.

What it runs, stores and sends

  • Hooks. SessionStart (startup, clear and compact) loads a waiting handover. PostToolUse and Stop check the context size for the nudge and the relay. Each hook runs node on a script in this plugin, and exits quietly if Node 18 or later is missing.
  • Git, read-only by default. The plugin runs git in your repo to read the branch, status and recent commits. It never switches your branch or touches your index or files, and outside web mode (below) it never commits or pushes.
  • Storage. Handovers are plain text files on your disk, in ~/.clear-resume. Each loaded handover is also copied to ~/.clear-resume/loaded/ for 30 days. Keep secrets out of them.
  • Network: nothing by default. No telemetry, no analytics, no web requests. Two opt-in modes use git and nothing else:
  • Sync pushes and pulls the store to a private git remote that you set up.
  • Web mode (CLEAR_RESUME_WEB=1, for Claude Code on the web) pushes each handover to its own clear-resume/<branch> branch on your repo's origin, and runs one git fetch origin at session start. Handovers found in git are listed as untrusted and never loaded on their own.

The relay mod (hooks/relay.ts)

A mod is a function-hooks module that runs inside Claude Code. This one runs the relay. It does nothing else.

  • When it is active. Only when Relay is on: the relay setting, or /relay typed in that window. Otherwise it registers the /relay command and the status line, and nothing more.
  • Programs it starts. One: git rev-parse HEAD in the current repo, with a 5 second timeout (the headOf function). The stall guard compares the commit before and after each continued session, to stop when two in a row make no new commit. If git is missing or fails, it carries on without the check. It starts no other program.
  • Slash command it runs. /clear, once after Claude saves a handover, and only while the number of clears used is below the budget you set.
  • Prompt it submits. After the clear, as if you typed it: "Continue from the clear-resume handover that was just loaded." It contains no conversation text.
  • Files it writes. A status file at ~/.clear-resume/relay/<key>.json (or $CLEAR_RESUME_HOME/relay/<key>.json). Fields: v, key, cwd, sessions (this window's session ids, up to 100), limit, configured, used, stalled, applied, updatedAt. Only the VS Code status bar reads it (the extension that ships with clear-resume), to show the clears left. The mod also reads <key>.set.json beside it, which the status bar writes to change the budget. It writes no settings, build, start-up or instructions file.
  • What it adds. The /relay command (off, on, unlimited or a number), the status line entry that shows the clears left, and toasts that report a stop or a refused command.
  • Network. It sends nothing anywhere. No data leaves your machine.
  • The command.run hook. It is the handler for the plugin's own /relay command, and it only answers that command. It does not filter, rewrite or decide on any other command.

Requirements

Node 18 or later and git. On Windows, Claude Code runs plugin hooks through Git Bash, which comes with Git for Windows.

More

MIT licence.

Source 1 files
hooks/relay.ts 397 lines
1import type { Register } from 'claude-code'
2
3// The relay: after Claude saves a handover, run /clear and submit the prompt
4// that continues from it, so nobody has to type anything. Opt-in through the
5// `relay` option, which is also the budget: how many clears this process may
6// run before it stops and leaves the next one to the user. /relay overrides
7// the option for this window (this process) only, and the status line shows
8// what is left while it is on.
9//
10// The module lives in the process and /clear keeps the process, so `pending`
11// and `used` survive the clear. A reload (a config change, an update) starts
12// them over. No session.start fires after a clear: session.end with reason
13// 'clear' is the signal that the new conversation is there, and by then the
14// SessionStart hook has loaded the handover.
15//
16// The VS Code extension's status bar reads this window's relay from a state file
17// the module writes, <store>/relay/<key>.json, and changes this window's budget
18// by writing <key>.set.json beside it, which the module reads before each clear.
19// Its "Hand over" button writes <key>.handover.json; the module polls for it once
20// a second, idle or not, and runs the handover skill, which the relay then clears
21// and continues from as after any other save. With the relay off it only saves.
22
23// What save.mjs prints on a good save (plugin/scripts/save.mjs).
24export const SAVED = 'Saved handover "'
25export const SAVE_SCRIPT = /scripts\/save\.mjs/
26
27export const RESUME_TEXT = 'Continue from the clear-resume handover that was just loaded.'
28
29// What the status bar's "Hand over" button runs, and the prompt sent instead if
30// the command is refused (a host that does not list plugin skills as commands).
31export const HANDOVER_COMMAND = 'clear-resume:handover'
32export const HANDOVER_TEXT = 'Write a clear-resume handover now, with the /clear-resume:handover skill.'
33const POLL_MS = 1000
34
35// "off" or unset is 0, "unlimited" has no cap, a number is that many clears.
36export function budget(raw: unknown): number {
37  const s = String(raw ?? 'off').trim().toLowerCase()
38  if (s === 'unlimited') return Infinity
39  const n = Number(s)
40  return Number.isInteger(n) && n > 0 ? n : 0
41}
42
43// The state file's shape. The extension reads it (plugin/packages/store/relay-state.mjs).
44export type RelayFile = {
45  v: 1
46  key: string
47  cwd: string
48  // This window's session ids, oldest first: one per clear, the chain.
49  sessions: string[]
50  limit: number | 'unlimited'
51  configured: number | 'unlimited'
52  used: number
53  stalled: number
54  // The `at` of the last override taken from <key>.set.json, or of the last
55  // /relay typed in the window; 0 for none.
56  applied: number
57  updatedAt: number
58}
59
60const asJson = (n: number): number | 'unlimited' => (n === Infinity ? 'unlimited' : n)
61
62type Engine = Parameters<Parameters<Parameters<Register>[0]>[2]>[0]
63
64// HEAD of the session's repo, or undefined with no git (or no $.process, which
65// is CLI only). Unknown counts as progress: the stall guard stands aside and the
66// budget still holds.
67async function headOf($: Engine): Promise<string | undefined> {
68  try {
69    const r = await $.process.run(['git', 'rev-parse', 'HEAD'], { timeoutMs: 5000 })
70    return r.exitCode === 0 ? r.stdout.trim() || undefined : undefined
71  } catch {
72    return undefined
73  }
74}
75
76// What /relay takes: off, on, unlimited or a number. "on" is the option's own
77// budget when that is on, else 3. Anything else is undefined: not understood.
78export function parseRelay(args: string, fallback: number): number | undefined {
79  const s = args.trim().toLowerCase()
80  if (s === 'on') return fallback > 0 ? fallback : 3
81  if (s === 'off') return 0
82  const n = budget(s)
83  return n === 0 ? undefined : n
84}
85
86export function relayStatus(limit: number, used: number): string {
87  if (limit === 0) return 'relay: off'
88  if (limit === Infinity) return `relay: on, unlimited (${used} used)`
89  return `relay: ${Math.max(0, limit - used)} of ${limit} left`
90}
91
92// <store>/relay, the store being CLEAR_RESUME_HOME or ~/.clear-resume as in
93// packages/store/store.mjs. Undefined when there is no home to put it under.
94export async function relayDir($: Engine): Promise<string | undefined> {
95  const own = await $.env.get('CLEAR_RESUME_HOME')
96  if (own) return `${own.replace(/[/\\]+$/, '')}/relay`
97  const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
98  return home ? `${home.replace(/[/\\]+$/, '')}/.clear-resume/relay` : undefined
99}
100
101async function headlessRun($: Engine): Promise<boolean> {
102  return /^(1|true|on|yes)$/i.test((await $.env.get('CLEAR_RESUME_HEADLESS')) ?? '')
103}
104
105// One process's relay. The module lives in the process, so this outlives /clear.
106type Relay = {
107  configured: number
108  limit: number
109  // This process's name in the relay folder. A reload starts the count over, so a
110  // new file is right; the extension reads the newest one for its folder.
111  key: string
112  sessions: string[]
113  applied: number
114  used: number
115  pending: boolean
116  // The stall guard, as in the headless runner (scripts/lib/run.mjs): HEAD at the
117  // last relay, and how many continued sessions in a row ended without a commit.
118  lastHead: string | undefined
119  stalled: number
120  headless: boolean
121  // The `at` of the last hand-over request taken from <key>.handover.json, and
122  // whether the poll for it is running. The poll's timer outlives /clear.
123  asked: number
124  polling: boolean
125  // The session last warned that the nudge went unanswered, so it warns once.
126  warned: string | undefined
127}
128
129// Told after a nudged turn ends with no save through save.mjs. Seen 2026-10-07: a
130// user's own skill also named "handover" saved by its own script, the relay never
131// armed, and nothing said so.
132export const UNARMED_TEXT =
133  'clear-resume relay: no handover was saved with /clear-resume:handover after the context nudge, so the relay will not clear. ' +
134  'If the work goes on, run /clear-resume:handover (another handover skill does not count), or type /clear.'
135
136// The nudge (scripts/lib/nudge.mjs) marks a session it has asked to hand over at
137// <store>/.nudged/<slug of the session id>, the slug as in scripts/lib/store.mjs.
138async function nudged($: Engine, id: string): Promise<boolean> {
139  const dir = await relayDir($)
140  const slug = id.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50)
141  if (!dir || !slug) return false
142  try {
143    await $.fs.read(`${dir.replace(/[/\\]relay$/, '')}/.nudged/${slug}`)
144    return true
145  } catch {
146    return false
147  }
148}
149
150// A finished turn with the relay on and nothing saved: if the nudge has asked this
151// session for a handover, say once that the relay will not clear.
152async function warnUnarmed($: Engine, r: Relay, reason: string): Promise<void> {
153  if (r.limit === 0 || reason !== 'answer' || (await headlessRun($))) return
154  const id = await $.session.id()
155  if (!id || r.warned === id || !(await nudged($, id))) return
156  r.warned = id
157  $.ui.toast(UNARMED_TEXT, { timeoutMs: 20000 })
158}
159
160// The standing status entry: what is left while the relay is on, nothing while off.
161const shown = (r: Relay) => (r.limit === 0 || r.headless ? undefined : relayStatus(r.limit, r.used))
162
163// Write this window's state for the status bar. A failure never reaches the relay.
164async function publish($: Engine, r: Relay): Promise<void> {
165  try {
166    if (await headlessRun($)) return
167    const dir = await relayDir($)
168    if (!dir) return
169    const id = await $.session.id()
170    if (id && r.sessions.at(-1) !== id) r.sessions.push(id)
171    if (r.sessions.length > 100) r.sessions.splice(0, r.sessions.length - 100)
172    const file: RelayFile = {
173      v: 1,
174      key: r.key,
175      cwd: await $.session.cwd(),
176      sessions: r.sessions,
177      limit: asJson(r.limit),
178      configured: asJson(r.configured),
179      used: r.used,
180      stalled: r.stalled,
181      applied: r.applied,
182      updatedAt: Date.now(),
183    }
184    await $.fs.write(`${dir}/${r.key}.json`, JSON.stringify(file))
185  } catch {
186    // The status bar is a view; the relay works without it.
187  }
188}
189
190// A new budget for this window: the count and the stall guard start over.
191function setLimit(r: Relay, limit: number, at: number): void {
192  r.applied = at
193  r.limit = limit
194  r.used = 0
195  r.stalled = 0
196  r.lastHead = undefined
197  if (limit === 0) r.pending = false
198}
199
200// A budget set from the status bar, newer than the last one taken, replaces this
201// window's budget.
202async function takeOverride($: Engine, r: Relay): Promise<void> {
203  try {
204    const dir = await relayDir($)
205    if (!dir) return
206    const set = JSON.parse(String(await $.fs.read(`${dir}/${r.key}.set.json`)))
207    if (typeof set?.at !== 'number' || set.at <= r.applied) return
208    setLimit(r, budget(set.limit), set.at)
209    $.ui.status(shown(r))
210  } catch {
211    // No override file, or a bad one: keep the budget there is.
212  }
213}
214
215// A hand-over request from the status bar, newer than the last one taken: run the
216// handover skill. Queued until the session is idle, so a click mid-turn waits.
217async function takeHandover($: Engine, r: Relay): Promise<void> {
218  let at: unknown
219  try {
220    const dir = await relayDir($)
221    if (!dir) return
222    at = JSON.parse(String(await $.fs.read(`${dir}/${r.key}.handover.json`)))?.at
223  } catch {
224    // No request file, or a bad one: nothing asked.
225    return
226  }
227  if (typeof at !== 'number' || at <= r.asked) return
228  r.asked = at
229  $.ui.toast('clear-resume: writing a handover', { timeoutMs: 5000 })
230  try {
231    await $.command.run({ command: HANDOVER_COMMAND, args: '' })
232  } catch {
233    await $.prompt.submit({ text: HANDOVER_TEXT, asUser: true }).catch(err => {
234      $.ui.toast(`clear-resume: hand-over refused: ${String(err)}`, { timeoutMs: 15000 })
235    })
236  }
237}
238
239// Start the poll once per process, from whichever of session.start and the first
240// turn's end comes first. Not under the headless runner, which has no button.
241async function poll($: Engine, r: Relay): Promise<void> {
242  if (r.polling) return
243  r.polling = true
244  if (await headlessRun($)) return
245  let busy = false
246  $.clock.every(POLL_MS, () => {
247    if (busy) return
248    busy = true
249    void takeHandover($, r).finally(() => {
250      busy = false
251    })
252  })
253}
254
255// What the end of a main-thread turn does with a pending save.
256async function decide($: Engine, r: Relay, reason: string): Promise<void> {
257  if (!r.pending) return warnUnarmed($, r, reason)
258  // Turned off for this window: drop the save quietly.
259  if (r.limit === 0) {
260    r.pending = false
261    return
262  }
263  // The headless runner (run.mjs) ends the process instead; it has its own budget.
264  if (await headlessRun($)) {
265    r.pending = false
266    return
267  }
268  // An interrupted or failed turn is the user's to finish: do not clear under them.
269  if (reason !== 'answer') {
270    r.pending = false
271    return
272  }
273  if (r.used >= r.limit) {
274    r.pending = false
275    $.ui.toast(`clear-resume relay: budget of ${r.limit} used. Type /clear to continue from the handover.`, {
276      timeoutMs: 15000,
277    })
278    return
279  }
280  const head = await headOf($)
281  r.stalled = head !== undefined && head === r.lastHead ? r.stalled + 1 : 0
282  r.lastHead = head
283  if (r.stalled >= 2) {
284    r.pending = false
285    $.ui.toast(
286      'clear-resume relay: stopped. Two continued sessions in a row made no new commit. Type /clear to continue from the handover.',
287      { timeoutMs: 15000 },
288    )
289    return
290  }
291  r.used++
292  $.ui.status('clear-resume: clearing...')
293  // Not awaited: command.run rejects inside a hook the turn is waiting on,
294  // and the clear is queued until the session is idle anyway.
295  $.clock.after(300, () => {
296    $.command.run({ command: 'clear' }).catch(err => {
297      r.pending = false
298      $.ui.status(shown(r))
299      $.ui.toast(`clear-resume relay: /clear refused: ${String(err)}`, { timeoutMs: 15000 })
300    })
301  })
302}
303
304export const register: Register = (on, options) => {
305  // Off still registers: /relay and the status bar can turn it on for this window.
306  const configured = budget(options.relay)
307  const r: Relay = {
308    configured,
309    limit: configured,
310    key: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
311    sessions: [],
312    applied: 0,
313    used: 0,
314    pending: false,
315    lastHead: undefined,
316    stalled: 0,
317    headless: false,
318    asked: 0,
319    polling: false,
320    warned: undefined,
321  }
322
323  on('session.start', async ($, e, next) => {
324    const result = await next(e)
325    r.headless = await headlessRun($)
326    await $.command.register({
327      name: 'relay',
328      description: 'Clear and continue by itself in this window: off, on, unlimited or a number',
329      argumentHint: '[off|on|unlimited|<n>]',
330      immediate: true,
331    })
332    $.ui.status(shown(r))
333    await publish($, r)
334    await poll($, r)
335    return result
336  })
337
338  // Bare /relay reports; with an argument it sets this window's budget and starts
339  // the count and the stall guard over. Typed later than any status-bar choice not
340  // yet taken, so it wins over that one.
341  on('command.run', { command: 'relay' }, async ($, e) => {
342    if (e.args.trim() === '') return { text: relayStatus(r.limit, r.used) }
343    const set = parseRelay(e.args, configured)
344    if (set === undefined) return { text: `relay: "${e.args.trim()}" not understood. Use off, on, unlimited or a number.` }
345    setLimit(r, set, Date.now())
346    $.ui.status(shown(r))
347    await publish($, r)
348    return { text: relayStatus(r.limit, r.used) }
349  })
350
351  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
352    const ran = await next(e)
353    if (
354      e.agentId === undefined &&
355      SAVE_SCRIPT.test(e.command.replace(/\\/g, '/')) &&
356      ran.deny === undefined &&
357      ran.isError !== true &&
358      (ran.text ?? '').includes(SAVED)
359    ) {
360      r.pending = true
361    }
362    return ran
363  })
364
365  on('turn.complete', async ($, e, next) => {
366    const result = await next(e)
367    if (e.agentId !== undefined) return result
368    await takeOverride($, r)
369    await decide($, r, e.reason)
370    await publish($, r)
371    await poll($, r)
372    return result
373  })
374
375  on('session.end', async ($, e, next) => {
376    const result = await next(e)
377    if (e.reason !== 'clear' || !r.pending) return result
378    r.pending = false
379    $.ui.status('clear-resume: continuing...')
380    $.clock.after(1000, () => {
381      $.prompt
382        .submit({ text: RESUME_TEXT, asUser: true })
383        .then(() => {
384          $.ui.status(shown(r))
385          // The VS Code panel draws no status line; a toast is the count it can show.
386          $.ui.toast(`clear-resume ${relayStatus(r.limit, r.used)}`, { timeoutMs: 8000 })
387          return publish($, r)
388        })
389        .catch(err => {
390          $.ui.status(shown(r))
391          $.ui.toast(`clear-resume relay: submit refused: ${String(err)}`, { timeoutMs: 15000 })
392        })
393    })
394    return result
395  })
396}
397