Saves a 3 line card when you stop (doing, next step, open question) and shows it above the prompt next time

A Claude Code mod that makes stopping painless. It writes a 3 line save card when you stop. The next time a session opens in that repo, the card shows above the prompt with a Continue button.
/plugin marketplace add 0xGondarxyz/claude-code-mods
/plugin install save-game@claude-code-mods
Or try it without installing:
git clone https://github.com/0xGondarxyz/claude-code-mods
claude --plugin-dir claude-code-mods/save-game
Mods are not sandboxed. They run with the same access as Claude Code. Read the source before you install any mod, including this one.
Doing: <what you and Claude were working on: files, feature, bug>
Next: <the single next step>
Open: <the open question or blocker, or "none">
Cards are kept per repo (the git toplevel of the session folder, else the folder). The store holds the 5 newest per repo. A newer card of the same session replaces the older one.
/save: makes a card now.Cards from 1 to 3 use a fork of the live session. It reuses the prompt cache, so it is cheap. The fork only works while the cache is warm (the shortest cache lifetime is 5 minutes, hence 4).
/save: make a card now and print it with full context: claude --resume <session id>. With no new turns it prints the newest card again./save list: the last 5 cards of this repo, each with its age, its 3 lines and its resume line.At session start, if this repo has a card younger than 14 days, a band shows above the prompt:
Saved 3h ago (session ab12cd3)
Doing: ...
Next: ...
Open: ...
[ Continue ] [ Dismiss ]
Continue sends Continue where I stopped. Save card: and the 3 lines as your first prompt. Dismiss hides the band. The band also hides after the first prompt you send. If a queued card for this repo finishes during startup, the band updates to it.
In claude -p and SDK runs the mod does nothing: no timers, no queue, no band, no /save.
None.
tail on the machine for the queue step (Linux and macOS have it).MIT
hooks/register.tsx 308 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Band, Card } from '../types'
5import {
6 CARD_PROMPT,
7 IDLE_MS,
8 RECENT_MS,
9 addCard,
10 cardLines,
11 continuePrompt,
12 isFresh,
13 listText,
14 nightKey,
15 parseCard,
16 projectDir,
17 saveReply,
18 savedHeader,
19 tailFromJsonl,
20 transcriptPrompt,
21} from './save'
22
23type Pending = { repo: string; sessionId: string; transcriptPath: string; endedAt: number }
24type Made = { kind: 'none' } | { kind: 'old' | 'new'; card: Card } | { kind: 'fail'; reason: string }
25
26const MAX_PENDING = 20
27const TAIL_BYTES = '3000000'
28
29const band = atom({ plugin: 'save-game', key: 'band' } as const, { card: null, isShown: false } as Band)
30
31// Module state resets on reload; a session start fills it again.
32let turns = 0
33let cardAt = 0
34let lastTurnAt = 0
35let lastNight = ''
36let isDone = false
37// False for -p and SDK runs: the mod does nothing there.
38let isLive = false
39let startCwd = ''
40let repoCache = ''
41let idle: Timer | undefined
42let night: Timer | undefined
43let chain: Promise<unknown> = Promise.resolve()
44
45// One model call at a time, so two triggers never fork twice.
46const serial = <T,>(fn: () => Promise<T>): Promise<T> => {
47 const next = chain.then(fn, fn)
48 chain = next.catch(() => undefined)
49 return next
50}
51
52async function toplevel($: EngineInterface, dir: string): Promise<string> {
53 try {
54 const r = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel'])
55 const top = r.stdout.trim()
56 if (r.exitCode === 0 && top !== '') return top
57 } catch {}
58 return dir
59}
60
61async function repoOf($: EngineInterface): Promise<string> {
62 if (repoCache === '') repoCache = await toplevel($, await $.session.cwd())
63 return repoCache
64}
65
66async function storedCards($: EngineInterface): Promise<Record<string, Card[]>> {
67 const v = await $.store.get('cards')
68 return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, Card[]>) : {}
69}
70
71async function saveCard($: EngineInterface, card: Card): Promise<void> {
72 await $.store.set('cards', addCard(await storedCards($), card))
73}
74
75// The fork runs over this session's own transcript while its prompt cache is warm.
76async function makeCard($: EngineInterface, force: boolean): Promise<Made> {
77 return serial(async (): Promise<Made> => {
78 try {
79 const repo = await repoOf($)
80 if (turns <= cardAt) {
81 if (!force) return { kind: 'none' }
82 const newest = (await storedCards($))[repo]?.[0]
83 return newest ? { kind: 'old', card: newest } : { kind: 'fail', reason: 'nothing to save yet' }
84 }
85 const seen = turns
86 const r = await $.model.fork({ prompt: CARD_PROMPT })
87 if (!r.isAnswered) return { kind: 'fail', reason: r.reason }
88 const parsed = parseCard(r.text)
89 if (!parsed) return { kind: 'fail', reason: 'the reply was not a 3 line card' }
90 const card: Card = { ...parsed, savedAt: await $.clock.now(), sessionId: await $.session.id(), repo }
91 await saveCard($, card)
92 cardAt = seen
93 return { kind: 'new', card }
94 } catch (err) {
95 return { kind: 'fail', reason: err instanceof Error ? err.message : 'error' }
96 }
97 })
98}
99
100async function autoCard($: EngineInterface): Promise<Made> {
101 const made = await makeCard($, false)
102 if (made.kind === 'fail') $.ui.toast(`save-game: no card saved: ${made.reason}`)
103 return made
104}
105
106async function checkNight($: EngineInterface): Promise<void> {
107 try {
108 const now = await $.clock.now()
109 const key = nightKey(now)
110 if (key === null || key === lastNight) return
111 if (lastTurnAt === 0 || now - lastTurnAt > RECENT_MS) return
112 lastNight = key
113 await autoCard($)
114 $.ui.toast('save-game: saved. It is past 1am, a good point to stop.')
115 } catch {}
116}
117
118async function showBand($: EngineInterface): Promise<void> {
119 const now = await $.clock.now()
120 const newest = (await storedCards($))[await repoOf($)]?.[0]
121 const card = newest && isFresh(newest, now) ? newest : null
122 await update($, band, () => ({ card, isShown: card !== null }))
123}
124
125// Turns a session that ended without a card into one, from its transcript file.
126async function cardFromTranscript($: EngineInterface, p: Pending): Promise<boolean> {
127 if (!(await $.fs.exists(p.transcriptPath))) return false
128 const r = await $.process.run(['tail', '-c', TAIL_BYTES, p.transcriptPath])
129 if (r.exitCode !== 0) return false
130 const tail = tailFromJsonl(r.stdout)
131 if (tail === '') return false
132 const reply = await $.model.complete({ model: 'sonnet', prompt: transcriptPrompt(tail), maxTokens: 400, timeoutMs: 120_000 })
133 if (!reply.isAnswered) return false
134 const parsed = parseCard(reply.text)
135 if (!parsed) return false
136 await saveCard($, { ...parsed, savedAt: p.endedAt, sessionId: p.sessionId, repo: p.repo })
137 return true
138}
139
140const isPending = (p: unknown): p is Pending =>
141 !!p &&
142 typeof (p as Pending).repo === 'string' &&
143 typeof (p as Pending).sessionId === 'string' &&
144 typeof (p as Pending).transcriptPath === 'string' &&
145 typeof (p as Pending).endedAt === 'number'
146
147async function drain($: EngineInterface): Promise<void> {
148 try {
149 const list = await $.store.get('pending')
150 if (!Array.isArray(list) || list.length === 0) return
151 await $.store.set('pending', [])
152 let made = false
153 for (const p of list) {
154 if (!isPending(p)) continue
155 try {
156 if (await cardFromTranscript($, p)) made = true
157 } catch {}
158 }
159 if (made && !isDone) await showBand($)
160 } catch {}
161}
162
163async function transcriptPath($: EngineInterface, cwd: string, sessionId: string): Promise<string | null> {
164 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
165 const path = `${dir}/projects/${projectDir(cwd)}/${sessionId}.jsonl`
166 return (await $.fs.exists(path)) ? path : null
167}
168
169export const register: Register = on => {
170 on('session.start', async ($, e, next) => {
171 isLive = e.isInteractive
172 if (!isLive) return next(e)
173 try {
174 startCwd = e.cwd
175 repoCache = await toplevel($, e.cwd)
176 await $.command.register({
177 name: 'save',
178 description: 'Save a 3 line card: what you were doing, the next step, the open question',
179 argumentHint: '[list]',
180 })
181 night?.cancel()
182 night = $.clock.every(60_000, () => {
183 void checkNight($)
184 })
185 await showBand($)
186 // Not awaited: cards for earlier sessions must not delay startup.
187 $.clock.after(0, () => {
188 void drain($)
189 })
190 } catch {}
191 return next(e)
192 })
193
194 on('turn.start', async ($, e, next) => {
195 idle?.cancel()
196 return next(e)
197 })
198
199 on('prompt.submit', async ($, e, next) => {
200 idle?.cancel()
201 isDone = true
202 try {
203 await update($, band, b => ({ ...b, isShown: false }))
204 } catch {}
205 return next(e)
206 })
207
208 on('turn.complete', async ($, e, next) => {
209 try {
210 if (isLive && e.agentId === undefined) {
211 turns += 1
212 lastTurnAt = await $.clock.now()
213 idle?.cancel()
214 idle = $.clock.after(IDLE_MS, () => {
215 void autoCard($)
216 })
217 }
218 } catch {}
219 return next(e)
220 })
221
222 on('command.run', { command: 'save' }, async ($, e, next) => {
223 if (!isLive) return next(e)
224 try {
225 const now = await $.clock.now()
226 if (e.args.trim() === 'list') {
227 const cards = (await storedCards($))[await repoOf($)] ?? []
228 return { text: listText(cards, now) }
229 }
230 const made = await makeCard($, true)
231 if (made.kind === 'fail') return { text: `save-game: no card saved: ${made.reason}` }
232 if (made.kind === 'none') return next(e)
233 return { text: saveReply(made.card) }
234 } catch (err) {
235 return { text: `save-game: no card saved: ${err instanceof Error ? err.message : 'error'}` }
236 }
237 })
238
239 // No model call here: the exit budget is short. The next session start writes the card.
240 on('session.end', async ($, e, next) => {
241 if (!isLive) return next(e)
242 try {
243 idle?.cancel()
244 night?.cancel()
245 if (turns > cardAt) {
246 const cwd = startCwd !== '' ? startCwd : await $.session.cwd()
247 const path = await transcriptPath($, cwd, e.sessionId)
248 if (path !== null) {
249 const old = await $.store.get('pending')
250 const entry: Pending = { repo: await repoOf($), sessionId: e.sessionId, transcriptPath: path, endedAt: await $.clock.now() }
251 await $.store.set('pending', [...(Array.isArray(old) ? old : []), entry].slice(-MAX_PENDING))
252 }
253 }
254 if (e.reason === 'clear') {
255 turns = 0
256 cardAt = 0
257 }
258 } catch {}
259 return next(e)
260 })
261
262 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
263 // The band has one slot shared with other mods: draw ours above whatever is beneath.
264 const inner = await next(e)
265 if (e.props.hasSurvey) return inner
266 try {
267 const { card, isShown } = await read($, band)
268 if (!isShown || card === null) return inner
269
270 const { Box, Button, Text } = $.ui.resolve(e)
271 const [doing, nextStep, open] = cardLines(card)
272 const hide = () => update($, band, b => ({ ...b, isShown: false }))
273 const onContinue = async () => {
274 isDone = true
275 const text = continuePrompt(card)
276 await hide()
277 try {
278 await $.prompt.submit({ text, asUser: true })
279 } catch {
280 $.clock.after(0, () => {
281 void $.prompt.submit({ text, asUser: true }).catch(() => undefined)
282 })
283 }
284 }
285 const onDismiss = async () => {
286 isDone = true
287 await hide()
288 }
289 return (
290 <Box flexDirection="column">
291 <Text bold>{savedHeader(card, await $.clock.now())}</Text>
292 <Text>{doing}</Text>
293 <Text>{nextStep}</Text>
294 <Text>{open}</Text>
295 <Box>
296 <Button key="save-continue" label="Continue" variant="primary" onPress={onContinue} />
297 <Text> </Text>
298 <Button key="save-dismiss" label="Dismiss" onPress={onDismiss} />
299 </Box>
300 {inner.type !== 'engine' ? inner : null}
301 </Box>
302 )
303 } catch {
304 return inner
305 }
306 })
307}
308hooks/save.ts 117 lines1import type { Card } from '../types'
2
3export const KEEP = 5
4export const MAX_AGE_MS = 14 * 24 * 3_600_000
5export const IDLE_MS = 4 * 60_000
6export const RECENT_MS = 30 * 60_000
7export const TAIL_CHARS = 30_000
8export const FIELD_MAX = 240
9
10export const CARD_PROMPT = [
11 'Write the save card for this session in exactly this 3 line format, plain text, no other text:',
12 'Doing: <what the user and you were working on, concrete: files, feature, bug>',
13 'Next: <the single next step>',
14 'Open: <the open question or blocker, or "none">',
15 'Each line under 110 characters.',
16].join('\n')
17
18export type Parsed = { doing: string; next: string; open: string }
19
20const clip = (s: string) => (s.length > FIELD_MAX ? `${s.slice(0, FIELD_MAX - 1)}…` : s)
21
22// Finds the three labelled lines, tolerating blank lines, bullets and bold marks.
23// Doing and Next are required; a missing or empty Open is "none".
24export function parseCard(text: string): Parsed | null {
25 const pick = (label: string): string => {
26 const re = new RegExp(`^[\\s>*_\\-]*${label}[*_]*\\s*:[*_]*\\s*(.*)$`, 'i')
27 for (const line of text.split('\n')) {
28 const m = re.exec(line)
29 if (m) return m[1]!.replace(/[*_]+$/, '').trim()
30 }
31 return ''
32 }
33 const doing = pick('doing')
34 const next = pick('next')
35 if (doing === '' || next === '') return null
36 return { doing: clip(doing), next: clip(next), open: clip(pick('open') || 'none') }
37}
38
39// Newest first, KEEP per repo. A newer card of the same session replaces the older one.
40export function addCard(all: Record<string, Card[]>, card: Card): Record<string, Card[]> {
41 const rest = (all[card.repo] ?? []).filter(c => c.sessionId !== card.sessionId)
42 return { ...all, [card.repo]: [card, ...rest].sort((a, b) => b.savedAt - a.savedAt).slice(0, KEEP) }
43}
44
45export function ageText(ms: number): string {
46 const min = Math.floor(ms / 60_000)
47 if (min < 1) return 'just now'
48 if (min < 60) return `${min}m ago`
49 const h = Math.floor(min / 60)
50 if (h < 48) return `${h}h ago`
51 return `${Math.floor(h / 24)}d ago`
52}
53
54export const shortId = (id: string) => id.slice(0, 7)
55export const resumeLine = (c: Card) => `full context: claude --resume ${c.sessionId}`
56export const cardLines = (c: Card) => [`Doing: ${c.doing}`, `Next: ${c.next}`, `Open: ${c.open}`]
57export const cardText = (c: Card) => cardLines(c).join('\n')
58export const savedHeader = (c: Card, now: number) => `Saved ${ageText(now - c.savedAt)} (session ${shortId(c.sessionId)})`
59export const continuePrompt = (c: Card) => `Continue where I stopped. Save card:\n${cardText(c)}`
60
61export const saveReply = (c: Card) => `${cardText(c)}\n${resumeLine(c)}`
62
63export function listText(cards: Card[], now: number): string {
64 if (cards.length === 0) return 'save-game: no saved cards for this repo yet.'
65 return cards
66 .slice(0, KEEP)
67 .map(c => [savedHeader(c, now), ...cardLines(c), resumeLine(c)].join('\n'))
68 .join('\n\n')
69}
70
71export const isFresh = (c: Card, now: number) => now - c.savedAt < MAX_AGE_MS
72
73export function projectDir(cwd: string): string {
74 return cwd.replace(/[/.]/g, '-')
75}
76
77// Local clock: the key of the night a 01:00 to 05:59 time belongs to, else null.
78export function nightKey(ms: number): string | null {
79 const d = new Date(ms)
80 const h = d.getHours()
81 if (h < 1 || h >= 6) return null
82 return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
83}
84
85// User prompts and assistant text only, last `max` chars, from the raw JSONL tail.
86// The first line may be cut in half by the tail, so lines that do not parse are skipped.
87export function tailFromJsonl(jsonl: string, max = TAIL_CHARS): string {
88 const parts: string[] = []
89 for (const line of jsonl.split('\n')) {
90 if (line.trim() === '') continue
91 let row: any
92 try {
93 row = JSON.parse(line)
94 } catch {
95 continue
96 }
97 if (!row || row.isMeta === true || row.isSidechain === true) continue
98 const content = row.message?.content
99 const text = Array.isArray(content)
100 ? content
101 .filter((b: any) => b && b.type === 'text' && typeof b.text === 'string')
102 .map((b: any) => b.text)
103 .join('\n')
104 : typeof content === 'string'
105 ? content
106 : ''
107 if (text.trim() === '') continue
108 if (row.type === 'user') parts.push(`User: ${text.trim()}`)
109 else if (row.type === 'assistant') parts.push(`Assistant: ${text.trim()}`)
110 }
111 const all = parts.join('\n\n')
112 return all.length > max ? all.slice(all.length - max) : all
113}
114
115export const transcriptPrompt = (tail: string) =>
116 `Below is the end of a work session between a user and an AI coding assistant.\n\n<transcript>\n${tail}\n</transcript>\n\n${CARD_PROMPT}`
117types/index.d.ts 22 lines1export type Card = {
2 doing: string
3 next: string
4 open: string
5 /** Milliseconds since the epoch. */
6 savedAt: number
7 sessionId: string
8 /** Git toplevel of the session cwd, else the cwd. */
9 repo: string
10}
11
12export type Band = {
13 card: Card | null
14 isShown: boolean
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'save-game': { band: Band }
20 }
21}
22