Detects a session that was cut off mid-turn (restart, crash) and drafts the standard pick-up prompt; /resume-here sends it.

When a session reopens after a restart or crash cut a turn off, this drafts the standard pick-up prompt in the prompt box: "The Mac restarted and this session was interrupted. Pick up exactly where you left off…". Press Enter to send it, or ignore it.
It treats the session as cut off when the last message is one of:
It is not cut off when the turn ended with an answer, or when you pressed Esc ([Request interrupted by user]).
/resume-here sends the pick-up prompt, only when the session is cut off. Use it from the phone, where the drafted text may not show./resume-here check reports the verdict and never sends.It never sends on its own. session.start does not fire after an in-session /resume; use /resume-here there.
Hooks: session.start, command.run. Calls: $.session.messages, $.prompt.fill, $.prompt.submit (only from /resume-here, on a timer once the command has answered), $.ui.toast, $.command.register.
Test: claude plugin test . (4 tests).
hooks/register.ts 55 lines1import type { Register, SessionMessage } from 'claude-code'
2
3// Wording taken from real restart recoveries, 2026-09.
4export const RESUME_TEXT =
5 'The Mac restarted and this session was interrupted. Pick up exactly where you left off and keep going on the original task. Re-check the state of anything that was mid-flight before repeating it.'
6
7// A deliberate Esc leaves this marker; that is a stop, not a cut-off.
8const USER_STOP = /^\[Request interrupted by user/
9
10export type Verdict = { isCut: boolean; why: string }
11
12// Was the conversation cut off mid-turn? Reads only the last message.
13export function cutOff(messages: SessionMessage[]): Verdict {
14 const last = messages.at(-1)
15 if (!last) return { isCut: false, why: 'empty conversation' }
16 if (last.role === 'assistant') {
17 const pending = last.toolUses.filter(t => t.text === undefined)
18 return pending.length
19 ? { isCut: true, why: `${pending.length} tool call${pending.length === 1 ? '' : 's'} never returned (${pending.map(t => t.tool).join(', ')})` }
20 : { isCut: false, why: 'last turn ended with an answer' }
21 }
22 if (USER_STOP.test(last.text.trim())) return { isCut: false, why: 'stopped on purpose (Esc)' }
23 if (last.toolResults?.length) return { isCut: true, why: 'a tool result came back but Claude never answered it' }
24 if (last.text.trim()) return { isCut: true, why: 'a prompt was sent but never answered' }
25 return { isCut: false, why: 'last message is empty' }
26}
27
28export const register: Register = on => {
29 on('session.start', async ($, e, next) => {
30 await $.command.register({
31 name: 'resume-here',
32 description: 'Send the pick-up prompt if this session was cut off (`check` only reports)',
33 argumentHint: '[check]',
34 })
35 const verdict = cutOff(await $.session.messages())
36 if (verdict.isCut) {
37 await $.prompt.fill({ text: RESUME_TEXT })
38 $.ui.toast(`resume-nudge: session was cut off (${verdict.why}). Pick-up prompt drafted; Enter to send, or /resume-here.`)
39 }
40 return next(e)
41 })
42
43 on('command.run', { command: 'resume-here' }, async ($, e) => {
44 const verdict = cutOff(await $.session.messages())
45 if (e.args.trim() === 'check') return { text: `resume-nudge: ${verdict.isCut ? 'cut off' : 'not cut off'} (${verdict.why}).` }
46 if (!verdict.isCut) return { text: `resume-nudge: nothing to resume (${verdict.why}). Type your next prompt as usual.` }
47 // A submit from inside command.run would wait on the turn this hook holds, so it goes
48 // out on a timer once the command has answered.
49 $.clock.after(0, () => {
50 $.prompt.submit({ text: RESUME_TEXT, asUser: true }).catch(err => $.ui.toast(`resume-nudge: could not send the pick-up prompt: ${String(err)}`))
51 })
52 return { text: `resume-nudge: sending the pick-up prompt (${verdict.why}).` }
53 })
54}
55