Warns when the session sits idle before the prompt cache expires, saves a handoff summary (on idle, /exit or /clear), and offers it to the next session in the…

hooks/register.tsx 346 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Handoff, Stage } from '../types'
5
6const MINUTE = 60_000
7const TICK = 30_000
8// The prompt cache lives an hour; a summary written later is billed afresh.
9const CACHE_MINUTES = 60
10
11const idleSince = atom({ plugin: 'idle-handoff', key: 'idleSince' } as const, null as number | null)
12const stage = atom({ plugin: 'idle-handoff', key: 'stage' } as const, 'none' as Stage)
13const offer = atom({ plugin: 'idle-handoff', key: 'offer' } as const, null as Handoff | null)
14const isDirty = atom({ plugin: 'idle-handoff', key: 'isDirty' } as const, false)
15
16type Settings = {
17 warnMs: number
18 graceMs: number
19 shouldSummarizeOnIdle: boolean
20 onExit: 'ask' | 'always' | 'never'
21 onStart: 'ask' | 'load' | 'off'
22 summaryWords: number
23 shouldNotifyDesktop: boolean
24}
25
26const positive = (value: unknown, fallback: number) =>
27 typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
28
29const oneOf = <T extends string>(value: unknown, allowed: readonly T[], fallback: T): T =>
30 allowed.includes(value as T) ? (value as T) : fallback
31
32const settingsOf = (options: PluginOptions): Settings => ({
33 warnMs: positive(options.idleWarnMinutes, 45) * MINUTE,
34 graceMs: positive(options.graceMinutes, 5) * MINUTE,
35 shouldSummarizeOnIdle: options.onIdle !== 'warn-only',
36 onExit: oneOf(options.onExit, ['ask', 'always', 'never'], 'ask'),
37 onStart: oneOf(options.onStart, ['ask', 'load', 'off'], 'ask'),
38 summaryWords: Math.round(positive(options.summaryWords, 300)),
39 shouldNotifyDesktop: options.desktopNotify !== false,
40})
41
42const summaryPrompt = (words: number) => `Write a handoff summary of this session so a fresh session in this project can pick up exactly where this one stopped.
43
44Markdown, under ${words} words, no preamble, these sections (skip one that would be empty):
45## Goal
46## Done
47## Current state
48## Next steps
49## Decisions & gotchas
50
51Name files, commands, branches and task IDs (plan.md / tasks.md) concretely.`
52
53const storeKey = (root: string) => `handoff:${root}`
54
55const pad = (n: number) => String(n).padStart(2, '0')
56
57const timeOf = (ms: number) => {
58 const d = new Date(ms)
59 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
60}
61
62const dateTimeOf = (ms: number) => {
63 const d = new Date(ms)
64 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${timeOf(ms)}`
65}
66
67const minutesOf = (ms: number) => Math.round(ms / MINUTE)
68
69const contextOf = (h: Handoff) =>
70 `Handoff summary saved by a previous session in this project on ${dateTimeOf(h.writtenAt)}. ` +
71 `Use it as context to continue the work:\n\n${h.summary}`
72
73const notifyDesktop = async ($: EngineInterface, text: string) => {
74 // Best effort: notify-send exists on most Linux desktops; elsewhere this fails quietly.
75 await $.process.run(['notify-send', '-a', 'Claude Code', 'Claude Code', text]).catch(() => undefined)
76}
77
78// Writes the summary and stores it for the next session; says whether it did.
79const writeSummary = async ($: EngineInterface, s: Settings) => {
80 $.ui.status('📝 writing handoff summary…')
81 const reply = await $.model.fork({ prompt: summaryPrompt(s.summaryWords) })
82 $.ui.status(undefined)
83
84 if (!reply.isAnswered) {
85 $.ui.log(`idle-handoff: no summary written (${reply.reason})`)
86 return false
87 }
88
89 const root = await $.session.root()
90 const handoff: Handoff = {
91 sessionId: await $.session.id(),
92 root,
93 writtenAt: await $.clock.now(),
94 summary: reply.text.trim(),
95 }
96 await $.store.set(storeKey(root), handoff)
97 await update($, isDirty, () => false)
98 $.ui.log(`idle-handoff: summary saved; the next session in ${root} will offer to load it.`)
99 return true
100}
101
102const warn = async ($: EngineInterface, s: Settings, since: number) => {
103 await update($, stage, () => 'warned')
104 const idle = `Idle ${minutesOf(s.warnMs)} min: the prompt cache expires soon.`
105 const text = s.shouldSummarizeOnIdle
106 ? `${idle} Reply by ${timeOf(since + s.warnMs + s.graceMs)} or a handoff summary is saved for the next session.`
107 : `${idle} Reply to keep it warm.`
108 // A toast stays a minute at most; the band and status line carry the warning after that.
109 $.ui.toast(text, { timeoutMs: Math.min(s.graceMs, MINUTE) })
110 if (s.shouldSummarizeOnIdle) {
111 $.ui.status(`⏳ handoff summary at ${timeOf(since + s.warnMs + s.graceMs)}`)
112 }
113 if (s.shouldNotifyDesktop) {
114 await notifyDesktop($, text)
115 }
116}
117
118const saveOnIdle = async ($: EngineInterface, s: Settings) => {
119 await update($, stage, () => 'saving')
120 const isSaved = await writeSummary($, s)
121 // The person may have come back while the summary was being written; their
122 // prompt already reset the stage, and the summary still stands.
123 if ((await read($, stage)) === 'saving') {
124 await update($, stage, () => 'saved')
125 }
126 return isSaved
127}
128
129const tick = async ($: EngineInterface, s: Settings) => {
130 const since = await read($, idleSince)
131 if (since === null) {
132 return
133 }
134
135 const idle = (await $.clock.now()) - since
136 const current = await read($, stage)
137
138 if (current === 'none' && idle >= s.warnMs) {
139 await warn($, s, since)
140 } else if (current === 'warned' && idle >= s.warnMs + s.graceMs) {
141 if (s.shouldSummarizeOnIdle && (await read($, isDirty))) {
142 await saveOnIdle($, s)
143 } else {
144 await update($, stage, () => 'saved')
145 $.ui.status(undefined)
146 }
147 }
148}
149
150const forget = async ($: EngineInterface, h: Handoff) => {
151 const key = storeKey(h.root)
152 const stored = (await $.store.get(key)) as Handoff | undefined
153 if (stored?.writtenAt === h.writtenAt) {
154 await $.store.delete(key)
155 }
156 await update($, offer, () => null)
157}
158
159const load = async ($: EngineInterface, h: Handoff) => {
160 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: contextOf(h) }] } })
161 await forget($, h)
162 $.ui.log(`idle-handoff: loaded the summary from ${dateTimeOf(h.writtenAt)} into context.\n\n${h.summary}`)
163}
164
165const offerSaved = async ($: EngineInterface, s: Settings, h: Handoff) => {
166 if (s.onStart === 'load') {
167 await load($, h)
168 } else if (s.onStart === 'ask') {
169 await update($, offer, () => h)
170 $.ui.toast(`A previous session left a handoff summary (${dateTimeOf(h.writtenAt)}).`, { timeoutMs: 10_000 })
171 }
172}
173
174// /clear goes on under a new session id without firing session.start, so the
175// fresh conversation is set up, and the saved summary offered, here.
176const startOver = async ($: EngineInterface, s: Settings) => {
177 await update($, idleSince, () => null)
178 await update($, stage, () => 'none')
179 await update($, isDirty, () => false)
180 $.ui.status(undefined)
181
182 const saved = (await $.store.get(storeKey(await $.session.root()))) as Handoff | undefined
183 if (saved !== undefined) {
184 await offerSaved($, s, saved)
185 }
186}
187
188const SAVE = 'Save summary'
189const SKIP = 'Skip'
190const CANCEL = 'Cancel'
191
192// Saves a summary before /exit or /clear as the settings say; false when cancelled.
193const saveBeforeLeaving = async ($: EngineInterface, s: Settings, command: string) => {
194 if (s.onExit === 'never' || !(await read($, isDirty))) {
195 return true
196 }
197
198 if (s.onExit === 'ask') {
199 const leaving = command === 'clear' ? 'clearing' : 'quitting'
200 const choice = await $.ui
201 .ask(`Save a handoff summary for the next session before ${leaving}?`, {
202 header: 'Handoff',
203 options: [SAVE, SKIP, CANCEL],
204 })
205 .catch(() => CANCEL)
206
207 if (choice === SKIP) {
208 return true
209 }
210 if (choice !== SAVE) {
211 return false
212 }
213 }
214
215 await writeSummary($, s)
216 return true
217}
218
219export const register: Register = (on, options) => {
220 const s = settingsOf(options)
221
222 on('session.start', async ($, e, next) => {
223 const result = await next(e)
224 if (!e.isInteractive) {
225 return result
226 }
227
228 await $.command.register({
229 name: 'handoff',
230 description: 'Load (default), save or discard the handoff summary for this project',
231 argumentHint: '[load|save|discard]',
232 })
233
234 if (minutesOf(s.warnMs + s.graceMs) >= CACHE_MINUTES) {
235 $.ui.log(
236 `idle-handoff: warn + grace is ${minutesOf(s.warnMs + s.graceMs)} min, past the ${CACHE_MINUTES}-min prompt cache; ` +
237 'the idle summary will be billed without the cache.',
238 )
239 }
240
241 const saved = (await $.store.get(storeKey(await $.session.root()))) as Handoff | undefined
242 if (saved !== undefined && saved.sessionId !== (await $.session.id())) {
243 await offerSaved($, s, saved)
244 }
245
246 $.clock.every(TICK, () => {
247 void tick($, s)
248 })
249
250 return result
251 })
252
253 on('prompt.submit', async ($, e, next) => {
254 await update($, idleSince, () => null)
255 if ((await read($, stage)) !== 'none') {
256 await update($, stage, () => 'none')
257 $.ui.status(undefined)
258 }
259 return next(e)
260 })
261
262 on('turn.complete', async ($, e, next) => {
263 if (e.agentId === undefined) {
264 const now = await $.clock.now()
265 await update($, idleSince, () => now)
266 await update($, isDirty, () => true)
267 }
268 return next(e)
269 })
270
271 on('command.run', { command: ['exit', 'clear'] }, async ($, e, next) => {
272 if (!(await saveBeforeLeaving($, s, e.command))) {
273 return { text: `/${e.command} cancelled.` }
274 }
275
276 const result = await next(e)
277 if (e.command === 'clear') {
278 await startOver($, s)
279 }
280 return result
281 })
282
283 on('command.run', { command: 'handoff' }, async ($, e) => {
284 const verb = e.args.trim()
285
286 if (verb === 'save') {
287 return (await writeSummary($, s))
288 ? { text: 'Saved a handoff summary for the next session.' }
289 : { text: 'No handoff summary was written.' }
290 }
291
292 const root = await $.session.root()
293 const saved = (await $.store.get(storeKey(root))) as Handoff | undefined
294 if (saved === undefined) {
295 return { text: 'No handoff summary is saved for this project.' }
296 }
297
298 await forget($, saved)
299 if (verb === 'discard') {
300 return { text: `Discarded the handoff summary from ${dateTimeOf(saved.writtenAt)}.` }
301 }
302 return {
303 text: `Loaded the handoff summary from ${dateTimeOf(saved.writtenAt)}:\n\n${saved.summary}`,
304 context: [contextOf(saved)],
305 }
306 })
307
308 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
309 if (e.props.hasSurvey) {
310 return next(e)
311 }
312
313 const { Box, Button, Text } = $.ui.resolve(e)
314 const pending = await read($, offer)
315
316 if (pending !== null) {
317 return (
318 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
319 <Text color="cyan">
320 📋 A previous session left a handoff summary ({dateTimeOf(pending.writtenAt)}). Load it into context?
321 </Text>
322 <Button key="load" label="Load" hotkey="l" variant="primary" onPress={() => load($, pending)} />
323 <Button key="discard" label="Discard" hotkey="d" onPress={() => forget($, pending)} />
324 <Button key="later" label="Later" role="dismiss" onPress={() => update($, offer, () => null)} />
325 </Box>
326 )
327 }
328
329 const since = await read($, idleSince)
330 if ((await read($, stage)) === 'warned' && since !== null) {
331 const idle = `⏳ Idle ${minutesOf(s.warnMs)} min: the prompt cache expires soon. Send anything to keep this session`
332 return (
333 <Box>
334 <Text color="yellow">
335 {s.shouldSummarizeOnIdle
336 ? `${idle}; otherwise a handoff summary is saved at ${timeOf(since + s.warnMs + s.graceMs)}.`
337 : `${idle}.`}
338 </Text>
339 </Box>
340 )
341 }
342
343 return next(e)
344 })
345}
346types/index.d.ts 23 lines1export type Handoff = {
2 sessionId: string
3 root: string
4 writtenAt: number
5 summary: string
6}
7
8// none: working or not idle long enough; warned: the 5-minute grace is running;
9// saving: the summary is being written; saved: written for this idle stretch.
10export type Stage = 'none' | 'warned' | 'saving' | 'saved'
11
12declare module 'claude-code' {
13 interface PluginState {
14 'idle-handoff': {
15 idleSince: number | null
16 stage: Stage
17 offer: Handoff | null
18 // A main-thread turn finished since the last summary was saved.
19 isDirty: boolean
20 }
21 }
22}
23