SLOPSHOPPER

auto-handoff

Writes a handoff document when a session has sat idle long enough that its prompt cache is about to go cold.

toastprompttimer
★ 103v0.1.0MITupdated 2026-10-20obie/auto-handoff
A shopper browsing a rack in a slop shop
README

auto-handoff

A Claude Code plugin that writes a handoff document when you walk away from a session.

Claude Code keeps a session's context in a prompt cache that expires an hour after the last request. A turn sent inside that hour reads the conversation back at a fraction of the usual input price. Once the hour is up, the next turn pays full price to read everything again, and on a long session that is slow and expensive.

auto-handoff watches the gap. When a session has sat idle for 50 minutes it runs a handoff command while the cache is still warm, so the state of your work lands in a file that a fresh session can resume from.

Install

/plugin marketplace add obie/auto-handoff
/plugin install auto-handoff@auto-handoff

The plugin loads at the next session start.

It is built on the function hooks plugin API, which is early access and may change between Claude Code releases. Development and testing happened on Claude Code 2.1.289.

How it behaves

  • The idle clock starts when a turn of the main conversation ends. Any new turn restarts it, whoever started that turn. A subagent finishing leaves it alone.
  • After minutes of idle time the plugin runs command as if you had typed it, and a toast says so.
  • You get one handoff per absence. The plugin then stays quiet until you send a prompt or run a command yourself. A background task reporting in, or a scheduled trigger firing, keeps it quiet.
  • /clear cancels a pending handoff.
  • A laptop that slept through the wait wakes with a cold cache. The plugin sees that its timer fired late and skips the handoff, since running it then would cost the full price it exists to avoid.

The bundled skill

The default command is /auto-handoff:handoff, a skill that ships with the plugin. You can run it by hand too.

It writes docs/handoffs/<timestamp>-<slug>.md, covering the goal, status, decisions, dead ends, files, verification commands, next steps and open questions. To do that it runs date and git status, then writes that one file. Nothing gets committed.

Nobody is there to answer a permission prompt when it runs, so the skill pre-approves those tools for itself through allowed-tools. It completes in the default permission mode.

Configuration

OptionDefaultMeaning
minutes50Idle time before the command runs
commandauto-handoff:handoffSlash command to run, as you would type it, with any arguments after its name

Set them with /plugin configure auto-handoff inside Claude Code, or from a shell:

echo '{"command": "my-plugin:my-handoff"}' | claude plugin configure auto-handoff@auto-handoff --values-stdin

Arguments go after the command's name, as they would at the prompt. A command that behaves differently when nobody is there can be told so: my-plugin:park idle-triggered.

A command of your own has to get through an unattended run. Give it allowed-tools for whatever it calls, or run your sessions in a permission mode that lets it write.

Limits

  • The default of 50 assumes the one-hour cache. A session that has dropped to the five-minute cache, as happens in usage overage, needs a much smaller minutes. The plugin cannot tell which cache a session has.
  • The handoff is a turn like any other. Each model request in it costs one cached read of the conversation, and the turn keeps the cache warm for another hour.
  • The timer lives in the Claude Code process, so it stops when you quit Claude Code.
  • A model that reaches for a tool the command has not pre-approved leaves the turn waiting at a permission prompt until you return.

Development

git clone https://github.com/obie/auto-handoff ~/projects/auto-handoff
ln -s ~/projects/auto-handoff ~/.claude/skills/auto-handoff

Claude Code loads the linked folder as auto-handoff@skills-dir at the next session start and reloads the hooks module when you save a file.

claude plugin validate .
claude plugin test .

The pieces:

  • hooks/register.ts is the hooks module: an idle timer armed at turn.complete and cancelled at turn.start.
  • types/index.d.ts declares the two values the module keeps in $.state.
  • skills/handoff/SKILL.md is the bundled handoff skill.
  • tests/ runs under claude plugin test against a mocked clock.

License

MIT

Source 2 files
hooks/register.ts 152 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  EngineInterface,
4  PromptOrigin,
5  Register,
6  Timer,
7} from 'claude-code'
8
9import type { IdleSince } from '../types'
10
11const MINUTE = 60_000
12const DEFAULT_MINUTES = 50
13
14// How late the timer may fire, as a share of the wait. A machine that slept
15// through the wait wakes with the cache already cold, and a handoff then
16// rereads the whole conversation at full price with nobody there to want it.
17const LATE_SHARE = 0.1
18
19// The origins that mean the person is back. A background task's notification
20// or a scheduled trigger starts a turn too, and neither says anyone is here.
21const PERSON: ReadonlySet<PromptOrigin['kind']> = new Set([
22  'composer',
23  'bridge',
24  'sdk',
25  'slack-ping',
26  'auto-continuation',
27])
28
29const idleSince = atom(
30  { plugin: 'auto-handoff', key: 'idleSince' } as const,
31  null as IdleSince,
32)
33const isDone = atom({ plugin: 'auto-handoff', key: 'isDone' } as const, false)
34
35type Plan = { wait: number; command: string; args: string }
36
37let timer: Timer | undefined
38
39function disarm() {
40  timer?.cancel()
41  timer = undefined
42}
43
44async function handOff($: EngineInterface, { wait, command, args }: Plan) {
45  timer = undefined
46  const since = await read($, idleSince)
47
48  // A turn started as the timer fired: the session is not idle after all.
49  if (since === null) {
50    return
51  }
52
53  await update($, idleSince, () => null)
54  // Set before the command runs, so its own turn does not arm the timer
55  // again and hand off every `wait` for as long as the person is away.
56  await update($, isDone, () => true)
57  const idle = (await $.clock.now()) - since
58  const minutes = Math.round(idle / MINUTE)
59
60  if (idle - wait > wait * LATE_SHARE) {
61    $.ui.toast(`idle for ${minutes} min, cache already cold: /${command} skipped`)
62
63    return
64  }
65
66  $.ui.toast(`idle for ${minutes} min, running /${command}`)
67
68  try {
69    await $.command.run({ command, args })
70  } catch (error) {
71    const reason = error instanceof Error ? error.message : String(error)
72    $.ui.toast(`/${command} did not run: ${reason}`)
73  }
74}
75
76function arm($: EngineInterface, ms: number, plan: Plan) {
77  disarm()
78  timer = $.clock.after(ms, () => void handOff($, plan))
79}
80
81async function wake($: EngineInterface, origin: PromptOrigin) {
82  if (PERSON.has(origin.kind)) {
83    await update($, isDone, () => false)
84  }
85}
86
87export const register: Register = (on, options) => {
88  const minutes = Number(options.minutes)
89  // The command reads as it would be typed: its name, then any arguments.
90  const [command, args = ''] = String(options.command)
91    .trim()
92    .replace(/^\//, '')
93    .split(/\s+(.*)/s)
94  const plan: Plan = {
95    wait: (minutes > 0 ? minutes : DEFAULT_MINUTES) * MINUTE,
96    command,
97    args,
98  }
99
100  // A reload drops the timer and keeps the state: pick the wait up where it
101  // stood, so an edit to this file half an hour in does not push it out.
102  on('session.start', async ($, e, next) => {
103    const since = await read($, idleSince)
104
105    if (since !== null) {
106      const now = await $.clock.now()
107      arm($, Math.max(0, since + plan.wait - now), plan)
108    }
109
110    return next(e)
111  })
112
113  on('prompt.submit', async ($, e, next) => {
114    await wake($, e.origin)
115
116    return next(e)
117  })
118
119  on('command.run', async ($, e, next) => {
120    await wake($, e.origin)
121
122    return next(e)
123  })
124
125  on('turn.start', async ($, e, next) => {
126    disarm()
127    await update($, idleSince, () => null)
128
129    return next(e)
130  })
131
132  on('turn.complete', async ($, e, next) => {
133    const isMainLoop = e.agentId === undefined
134
135    if (isMainLoop && !(await read($, isDone))) {
136      const now = await $.clock.now()
137      await update($, idleSince, () => now)
138      arm($, plan.wait, plan)
139    }
140
141    return next(e)
142  })
143
144  on('session.end', async ($, e, next) => {
145    disarm()
146    await update($, idleSince, () => null)
147    await update($, isDone, () => false)
148
149    return next(e)
150  })
151}
152
types/index.d.ts 12 lines
1/**
2 * When the main loop last went idle, in `$.clock.now()`'s milliseconds; null
3 * while a turn runs, once the handoff has run, and after a /clear.
4 */
5export type IdleSince = number | null
6
7declare module 'claude-code' {
8  interface PluginState {
9    'auto-handoff': { idleSince: IdleSince; isDone: boolean }
10  }
11}
12