SLOPSHOPPER

resume

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

newbandguardpromptprocesstimer
A shopper browsing a rack in a slop shop
README

resume

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:

  • Resume sends a prompt telling Claude the session was interrupted, with your last request, branch and changed files, and asks it to check the real state (git, project notes or handoff files, if any) before continuing.
  • Dismiss hides the band.

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.

Install

/plugin marketplace add danielmalka/malka-claude-mods
/plugin install resume@malka-claude-mods

Uninstall

/plugin uninstall resume@malka-claude-mods

Development

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.


Português

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.

Source 2 files
hooks/register.tsx 146 lines
1import { 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}
146
types/index.d.ts 16 lines
1export 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