Detects a previous session that was interrupted mid-turn and offers Resume/Dismiss in a band above the prompt.

Detects that the previous session in the same directory was interrupted in the middle of a turn (crash, closed terminal, machine restart) and shows a band above the prompt with two buttons:
It keeps a small heartbeat (every 30 s) so another live session in the same directory is not mistaken for a crash, and it does nothing in non-interactive runs (claude -p). The checkpoint is stored locally by Claude Code.
/plugin marketplace add danielmalka/malka-claude-mods
/plugin install resume@malka-claude-mods
/plugin uninstall resume@malka-claude-mods
Type declarations in .claude-plugin/types/ are generated by Claude Code when the mod loads (not committed); the mod's own state contract is types/index.d.ts. Then claude plugin test plugins/resume and tsc -p plugins/resume.
Detecta que a sessão anterior no mesmo diretório foi interrompida no meio de um turno e mostra uma faixa acima do prompt com dois botões: Resume (envia um prompt com o último pedido, branch e arquivos alterados, pedindo para conferir o estado real antes de continuar) e Dismiss (esconde a faixa). Usa um batimento de 30 s para não confundir outra sessão viva com uma queda e não faz nada em execuções não interativas (claude -p).
Instalar: /plugin marketplace add danielmalka/malka-claude-mods e depois /plugin install resume@malka-claude-mods. Desinstalar: /plugin uninstall resume@malka-claude-mods.
hooks/register.tsx 146 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ResumeCrash } from '../types'
5
6const crash = atom({ plugin: 'resume', key: 'crash' } as const, null)
7
8type Checkpoint = Omit<ResumeCrash, 'at'> & { open: boolean; startedAt: number; lastSeen: number }
9
10const key = (cwd: string) => `resume:${cwd}`
11const hhmm = (ms: number) => {
12 const d = new Date(ms)
13 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
14}
15const oneLine = (s: string, n: number) => s.replace(/\s+/g, ' ').trim().slice(0, n)
16
17let lastPrompt = ''
18let interactive = false // non-interactive runs (claude -p) do not write a checkpoint
19let beat: { cancel: () => void } | undefined
20// ponytail: a 30s heartbeat tells "another live session in the same cwd" from "crashed"; a restart within 90s goes unnoticed
21const BEAT_MS = 30_000
22const ALIVE_MS = 90_000
23
24async function save($: EngineInterface, patch: Partial<Checkpoint>) {
25 if (!interactive) return
26 const cwd = await $.session.cwd()
27 const old = (await $.store.get(key(cwd))) as Checkpoint | undefined
28 // only activity from an open turn of this session (another session in the same cwd may have overwritten it)
29 if (patch.open === undefined && (!old?.open || old.sessionId !== (await $.session.id()))) return
30 await $.store.set(key(cwd), { ...old, ...patch, cwd })
31}
32
33// ponytail: a single `git status --porcelain --branch`; no git or an error = no branch and no files.
34async function gitState($: EngineInterface, cwd: string) {
35 try {
36 const r = await $.process.run(['git', '-C', cwd, 'status', '--porcelain', '--branch'])
37 if (r.exitCode !== 0) return {}
38 const [head = '', ...files] = r.stdout.split('\n').filter(Boolean)
39 const branch = head.replace(/^## (No commits yet on )?/, '').split(/\.\.\.| /)[0]
40 return { branch, dirty: files.slice(0, 10), dirtyCount: files.length }
41 } catch {
42 return {}
43 }
44}
45
46async function clear($: EngineInterface, cwd: string) {
47 await $.store.delete(key(cwd))
48 await update($, crash, () => null)
49}
50
51function resumePrompt(c: ResumeCrash) {
52 const files = c.dirty.length > 0 ? `\nChanged files (${c.dirtyCount}):\n${c.dirty.join('\n')}` : ''
53 return (
54 `The previous session was interrupted at ${hhmm(c.at)}.\n` +
55 `Last request: "${c.lastPrompt}"\n` +
56 (c.branch ? `Branch: ${c.branch}\n` : '') +
57 `${files}\n\n` +
58 'Check the actual state before acting: git status and git log, and any project notes or handoff files. ' +
59 'Then continue where it left off, without redoing what is already done.'
60 )
61}
62
63export const register: Register = on => {
64 on('prompt.submit', ($, e, next) => {
65 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge' || e.origin.kind === 'sdk') {
66 lastPrompt = e.text.trim().slice(0, 300)
67 }
68 return next(e)
69 })
70
71 // turn.start is only raised by the main loop: a subagent turn does not raise it.
72 on('turn.start', async ($, e, next) => {
73 const cwd = await $.session.cwd()
74 const now = await $.clock.now()
75 await save($, {
76 open: true,
77 sessionId: await $.session.id(),
78 startedAt: now,
79 lastSeen: now,
80 lastPrompt: lastPrompt || oneLine(e.text, 300),
81 dirty: [],
82 dirtyCount: 0,
83 ...(await gitState($, cwd)),
84 })
85 beat ??= $.clock.every(BEAT_MS, async () => save($, { lastSeen: await $.clock.now() }))
86 return next(e)
87 })
88
89 // pulse: the "interruption time" is the last activity seen, not the turn start.
90 on('tool.call', async ($, e, next) => {
91 await save($, { lastSeen: await $.clock.now() })
92 return next(e)
93 })
94
95 on('turn.complete', async ($, e, next) => {
96 if (e.agentId === undefined) {
97 beat?.cancel()
98 beat = undefined
99 await save($, { open: false })
100 }
101 return next(e)
102 })
103
104 // session.start does not carry the origin (startup/resume/reload): what separates an interruption from a reload is the sessionId.
105 on('session.start', async ($, e, next) => {
106 interactive = e.isInteractive
107 if (e.isInteractive) {
108 const old = (await $.store.get(key(e.cwd))) as Checkpoint | undefined
109 const isAlive = old !== undefined && (await $.clock.now()) - old.lastSeen < ALIVE_MS
110 if (old?.open && !isAlive && old.sessionId !== (await $.session.id())) {
111 await update($, crash, () => ({ ...old, at: old.lastSeen }))
112 }
113 }
114 return next(e)
115 })
116
117 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
118 const c = await read($, crash)
119 if (e.props.hasSurvey || c === null) return next(e)
120
121 const files = c.branch === undefined ? '' : ` · ${c.dirtyCount} changed files`
122 const text = `⚠ The previous session was interrupted at ${hhmm(c.at)} during: "${oneLine(c.lastPrompt, 60)}" · ${c.branch ?? 'no git'}${files}`
123 const under = await next(e)
124 const { Box, Button, Text } = $.ui.resolve(e)
125 return (
126 <Box flexDirection="column">
127 {under}
128 <Box>
129 <Text wrap="truncate-end">{text.slice(0, Math.max(10, e.props.bodyColumns - 24))} </Text>
130 <Button
131 key="resume"
132 label="Resume"
133 variant="primary"
134 onPress={async () => {
135 await clear($, c.cwd)
136 void $.prompt.submit({ text: resumePrompt(c) })
137 }}
138 />
139 <Text> </Text>
140 <Button key="dismiss" label="Dismiss" onPress={() => clear($, c.cwd)} />
141 </Box>
142 </Box>
143 )
144 })
145}
146types/index.d.ts 16 lines1export type ResumeCrash = {
2 cwd: string
3 sessionId: string
4 at: number // last activity seen before the interruption (ms)
5 lastPrompt: string
6 branch?: string
7 dirty: string[] // up to 10 lines of `git status --porcelain`
8 dirtyCount: number
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 resume: { crash: ResumeCrash | null }
14 }
15}
16