A save point before compaction: the task's goal, step, branch, links, decisions, rules and next step are saved before /compact and handed back to the model…

█▀█ █ █ █ █▀▀ █▄▀ █▀ ▄▀█ █ █ █▀▀
▀▀█ █▄█ █ █▄▄ █ █ ▄█ █▀█ ▀▄▀ ██▄ SAVE POINT ▸

The problem: compaction rewrites a long session into a summary, and the summary loses the plot: the exact goal, the branch, the PR you were on, the decisions already made, and the rules you set an hour ago. The study behind this library counted 91 compactions, summaries rewritten by hand afterwards, and "is now a safe time to compact?" asked again and again.
quicksave writes a save point just before every compaction and hands it back to the model straight after, so the session carries on from the save, not from a vague summary. When the context is filling up and nothing is running, a green SAVE POINT band tells you it is a safe moment to compact.
/plugin marketplace add pourya7/claude-code-mods
/plugin install quicksave@claude-code-mods
/compact, the automatic one at the threshold, or one a plugin starts), quicksave first forks the conversation with $.model.fork and asks for a JSON save:$.store under the session id. The 5 newest saves are kept as slots, newest first. Slots are kept for the 20 most recent sessions; older sessions' slots are removed.QUICKSAVE (slot 1, …). If the compaction kept an older save note (from an earlier compaction, or from /quickload of an older slot), that note is dropped, so the model reads exactly one save: the fresh one.QUICKSAVE ✕ SAVE FAILED (<reason>). Compacting anyway.warnAtPercent full (70% by default)SAVING… HOLD STILL. Pressing a button again, running /quicksave, or a compaction that starts meanwhile all wait for that save and use it, so there is never a second fork or a lost slot. A second press of [ SAVE + COMPACT ] while the first is still running does nothing.writeFile on, each save is also written to <session cwd>/.claude/quicksave/<session-id>.md. It is off by default.| Command | What it does |
|---|---|
/quicksave | Saves now, without compacting, into slot 1. |
/quicksave list | Shows the slots, newest first: number, time (UTC), what made the save, and the goal. |
/quickload | Hands slot 1 back to the model, as a hidden note it reads with your next message. |
/quickload <n> | The same for slot n (1 to 5). |
The band's buttons have hotkeys. Once the band has focus (ctrl+x tab or a click), press s for SAVE or c for SAVE + COMPACT.
[ SAVE ] saves into slot 1 and the band shows SAVED ★ SLOT 1.[ SAVE + COMPACT ] saves, then compacts. The save is passed to the summarizer as compaction instructions, and it is appended to the conversation again once the compaction stands.userConfig)| Field | Type | Default | Meaning |
|---|---|---|---|
warnAtPercent | number | 70 | Show the SAVE POINT band once the context window is this full (1 to 100) and the session is idle. |
writeFile | boolean | false | Also write each save to <session cwd>/.claude/quicksave/<session-id>.md. |
You can change these in the /config menu, or under pluginConfigs.quicksave in your settings.
The band above the prompt, with the context 78% full and nothing running:
SAVE POINT ▸ safe to /compact QUICKSAVE
█▀▀▀▀██▄ CONTEXT ████████░░ 78%
████████ IDLE · NOTHING RUNNING
█▀▀▀▀▀▀█ SAVES GOAL, BRANCH, RULES, NEXT STEP
█▄▄▄▄▄▄█ /quickload HANDS IT BACK
[ SAVE ] [ SAVE + COMPACT ]
This capture is plain text. In the terminal the floppy disk is drawn in half-block pixels: green, with a light grey shutter, a white label and a lime shine. The bar is green on dark grey. All colours come from the PICO-8 palette.
/quicksave list after a few saves:
QUICKSAVE ▸ SLOTS (newest first)
1 ★ 2026-10-03 12:04 AUTO Ship the retry fix for the uploader
2 ● 2026-10-03 11:20 COMMAND Ship the retry fix for the uploader
3 ● 2026-10-03 10:02 MANUAL Reproduce the upload timeout
/quickload [n] hands slot n back to the model.
What the model reads after a compaction:
QUICKSAVE (slot 1, saved 2026-10-03 12:04 UTC, auto). This is the save point written before the conversation was compacted. Continue the task from it; the user's rules below still apply.
GOAL: Ship the retry fix for the uploader
CURRENT STEP: Writing the regression test
WORKING DIRECTORY: ~/work/app (branch fix/upload-retry)
OPEN LINKS:
- PR #42
DECISIONS:
- Retry 3 times with jitter
RULES THE USER SET:
- No force push
NEXT STEP: Run the test suite
VS Code and claude -p have no band. There, the commands and the toasts are what you see.
| Network | Runs processes | Files | Calls a model | Auto-submits prompts | Data leaving the machine |
|---|---|---|---|---|---|
None. No $.http. | None. No $.process. | Only with writeFile on: $.fs.write to <session cwd>/.claude/quicksave/<session-id>.md. Saves are kept in $.store (slots/<session-id>, 5 per session, the 20 most recent sessions). The band's state is in $.state. | Yes. One $.model.fork per save: before each main-conversation compaction, on /quicksave, and on the band's buttons. The fork is one tool-less request over the session's own transcript, on your own model. | No prompts. [ SAVE + COMPACT ] calls $.session.compact when you press it, then $.session.append once to add the save as a user-role note. | Only the fork request, which goes to your own model provider like any other turn. |
session.compact hook returns. It is not added with a separate $.session.append, because an append made inside the compaction hook would race the transcript being replaced. $.session.append is used only after [ SAVE + COMPACT ], where the engine skips quicksave's own compaction hook. If that append is refused, a toast points you to /quickload 1.precompute compaction installs nothing, and the compaction that later uses it runs the hook again, so quicksave waits for that one. Subagents' own compactions are left alone.$.agent.list). The mods API does not list background shells or monitors, so the band can show while one of those is running.claude plugin validate quicksave
claude plugin test quicksave
Pure logic lives in hooks/save.ts (the save prompt, parsing, slots, the note and the band rule) and hooks/pixels.ts (the half-block renderer and the floppy sprite). hooks/register.tsx connects them to the engine. The tests answer $.model.fork and session.compact from test hooks, and mount the band on both terminal and desktop.
hooks/register.tsx 313 lines1import { atom, read, update } from 'claude-code'
2import type { Register, SessionMessage } from 'claude-code'
3
4import type { QuicksaveBand } from '../types'
5import { FLOPPY, PALETTE, pixelRows } from './pixels'
6import type { Run } from './pixels'
7import {
8 PLUGIN,
9 SAVE_PROMPT,
10 compactInstructions,
11 failureText,
12 parseSave,
13 percentOption,
14 pickSlot,
15 pushSlot,
16 readSlots,
17 touchSession,
18 saveMarkdown,
19 saveNote,
20 shouldShowBand,
21 slotList,
22 stamp,
23 withNote,
24 writeFileOption,
25} from './save'
26import type { Slot } from './save'
27
28const band = atom({ plugin: 'quicksave', key: 'band' } as const, {} as QuicksaveBand)
29
30type Dollar = Parameters<Parameters<Parameters<Register>[0]>[1]>[0]
31type Settings = { threshold: number; writeFile: boolean }
32type Taken = { slot: Slot } | { failure: string }
33
34const slotsKey = (sessionId: string): string => `slots/${sessionId}`
35const SESSIONS_KEY = 'sessions'
36
37// The note `[ SAVE + COMPACT ]` hands to its own compaction while that call
38// is in flight. Nothing here needs to survive a reload: a reload cancels the
39// call with it.
40let ownCompaction: { note: string; isInjected: boolean } | undefined
41
42// The save being taken now. A second request while it runs (a double press,
43// /quicksave, a compaction) waits for this one instead of starting another
44// fork, so two read-modify-writes of the slot list never race.
45let inFlight: Promise<Taken> | undefined
46
47// True while `[ SAVE + COMPACT ]` runs, so a second press starts no second compaction.
48let isCompacting = false
49
50async function readStoredSlots($: Dollar): Promise<Slot[]> {
51 try {
52 return readSlots(await $.store.get(slotsKey(await $.session.id())))
53 } catch {
54 return []
55 }
56}
57
58/** One save at a time: joins the save in flight, or starts one and shows SAVING on the band while it runs. */
59function takeSave($: Dollar, settings: Settings, trigger: string): Promise<Taken> {
60 if (inFlight !== undefined) return inFlight
61 const run = (async (): Promise<Taken> => {
62 await update($, band, value => ({ ...value, isSaving: true })).catch(() => undefined)
63 try {
64 return await forkAndStore($, settings, trigger)
65 } finally {
66 inFlight = undefined
67 await update($, band, ({ isSaving: _, ...rest }) => rest).catch(() => undefined)
68 }
69 })()
70 inFlight = run
71 return run
72}
73
74/** Forks the conversation for a save, keeps it in the newest slot, mirrors it when asked. */
75async function forkAndStore($: Dollar, settings: Settings, trigger: string): Promise<Taken> {
76 let reply: Awaited<ReturnType<Dollar['model']['fork']>>
77 try {
78 reply = await $.model.fork({ prompt: SAVE_PROMPT })
79 } catch {
80 return { failure: 'fork-error' }
81 }
82 if (!reply.isAnswered) return { failure: reply.reason }
83
84 const slot: Slot = { at: await $.clock.now(), trigger, save: parseSave(reply.text) }
85 let sessionId = 'session'
86 try {
87 sessionId = await $.session.id()
88 const slots = readSlots(await $.store.get(slotsKey(sessionId)))
89 await $.store.set(slotsKey(sessionId), pushSlot(slots, slot))
90 const { keep, drop } = touchSession(await $.store.get(SESSIONS_KEY), sessionId)
91 await $.store.set(SESSIONS_KEY, keep)
92 for (const old of drop) await $.store.delete(slotsKey(old))
93 } catch {
94 $.ui.toast('QUICKSAVE ✕ the save could not be stored')
95 return { failure: 'store-error' }
96 }
97
98 if (settings.writeFile) {
99 try {
100 const cwd = await $.session.cwd()
101 await $.fs.write(`${cwd}/.claude/quicksave/${sessionId}.md`, saveMarkdown(slot, sessionId))
102 } catch {
103 $.ui.toast('QUICKSAVE ✕ could not write the save file')
104 }
105 }
106
107 $.ui.toast(`QUICKSAVE ★ SLOT 1 SAVED ${stamp(slot.at).slice(11)}`)
108 return { slot }
109}
110
111async function saveFromBand($: Dollar, settings: Settings): Promise<Slot | undefined> {
112 const taken = await takeSave($, settings, 'band')
113 if ('failure' in taken) {
114 $.ui.toast(`QUICKSAVE ✕ SAVE FAILED (${taken.failure})`)
115 return undefined
116 }
117 await update($, band, value => ({ ...value, savedSlot: 1 }))
118 return taken.slot
119}
120
121/** `[ SAVE + COMPACT ]`: save, then compact with the save in the instructions and handed back after. */
122async function saveAndCompact($: Dollar, settings: Settings): Promise<void> {
123 if (isCompacting) return
124 isCompacting = true
125 try {
126 await saveThenCompact($, settings)
127 } finally {
128 isCompacting = false
129 }
130}
131
132async function saveThenCompact($: Dollar, settings: Settings): Promise<void> {
133 const slot = await saveFromBand($, settings)
134 const note = slot === undefined ? undefined : saveNote(slot, 1)
135 ownCompaction = note === undefined ? undefined : { note, isInjected: false }
136 try {
137 const result = await $.session.compact(note === undefined ? {} : { instructions: compactInstructions(note) })
138 if (result.skip !== undefined) {
139 $.ui.toast(`QUICKSAVE ▸ compaction skipped: ${result.skip}`)
140 return
141 }
142 await update($, band, () => ({}))
143 // Our own session.compact hook is skipped for our own call, so the save
144 // goes back in as a user-role row once the compacted transcript stands.
145 if (note !== undefined && !ownCompaction?.isInjected) {
146 try {
147 const appended = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: note }] } })
148 if (appended.deny !== undefined) throw new Error(appended.deny)
149 } catch {
150 $.ui.toast('QUICKSAVE ▸ compacted. /quickload 1 hands the save back')
151 }
152 }
153 } catch {
154 $.ui.toast('QUICKSAVE ✕ could not compact now')
155 } finally {
156 ownCompaction = undefined
157 }
158}
159
160export const register: Register = (on, options) => {
161 const settings: Settings = {
162 threshold: percentOption(options.warnAtPercent),
163 writeFile: writeFileOption(options.writeFile),
164 }
165
166 on('session.start', async ($, e, next) => {
167 try {
168 await $.command.register({
169 name: 'quicksave',
170 description: 'Save the task state now; /quicksave list shows the slots',
171 argumentHint: '[list]',
172 })
173 await $.command.register({
174 name: 'quickload',
175 description: 'Hand a save back to the model (1 = newest)',
176 argumentHint: '[slot]',
177 })
178 } catch {
179 $.ui.toast('QUICKSAVE ✕ /quicksave could not be registered')
180 }
181 return next(e)
182 })
183
184 on('session.compact', async ($, e, next) => {
185 // A precompute installs nothing (the compaction it serves raises this
186 // hook again), and a subagent's compaction is not the session's plot.
187 if (e.trigger === 'precompute' || e.agentId !== undefined) return next(e)
188
189 if (ownCompaction !== undefined) {
190 const own = ownCompaction
191 const result = await next(e)
192 if (result.skip !== undefined) return result
193 own.isInjected = true
194 return { ...result, messages: withNote(result.messages, own.note) as SessionMessage[] }
195 }
196
197 const taken = await takeSave($, settings, e.trigger)
198 if ('failure' in taken) $.ui.toast(failureText(taken.failure))
199 const result = await next(e)
200 if (result.skip !== undefined) return result
201 await update($, band, () => ({}))
202 if ('failure' in taken) return result
203
204 return { ...result, messages: withNote(result.messages, saveNote(taken.slot, 1)) as SessionMessage[] }
205 })
206
207 on('session.measure', async ($, e, next) => {
208 const result = await next(e)
209 if (e.changed.includes('context')) {
210 const percent = e.context.percent
211 await update($, band, value => ({
212 ...(value.isSaving === true ? { isSaving: true } : {}),
213 ...(percent === undefined ? {} : { percent }),
214 }))
215 }
216 return result
217 })
218
219 on('command.run', { command: 'quicksave' }, async ($, e) => {
220 const word = e.args.trim().toLowerCase()
221 if (word === 'list') return { text: slotList(await readStoredSlots($)) }
222 if (word !== '') return { text: 'Usage: /quicksave (save now) or /quicksave list' }
223
224 const taken = await takeSave($, settings, 'command')
225 if ('failure' in taken) return { text: `QUICKSAVE ✕ SAVE FAILED (${taken.failure}). Nothing was stored.` }
226 const goal = taken.slot.save.goal || '(no goal recorded)'
227 return { text: `QUICKSAVE ★ SAVED TO SLOT 1 · ${goal}\n/quickload hands it back to the model.` }
228 })
229
230 on('command.run', { command: 'quickload' }, async ($, e) => {
231 const word = e.args.trim()
232 const slot = pickSlot(await readStoredSlots($), word)
233 const number = word === '' ? 1 : Number(word)
234 if (slot === undefined || !Number.isInteger(number)) {
235 return { text: `QUICKSAVE ▸ NO SAVE IN SLOT ${word || '1'}. /quicksave list shows what there is.` }
236 }
237 return {
238 text: `QUICKSAVE ▸ LOADED SLOT ${number} (${stamp(slot.at)} UTC). The model reads it with your next message.`,
239 context: [saveNote(slot, number)],
240 }
241 })
242
243 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
244 if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
245 const current = await read($, band)
246 const { percent } = current
247 const isCandidate = shouldShowBand({ percent, threshold: settings.threshold, isWorking: e.props.isWorking, runningAgents: 0 })
248 if (!isCandidate) return next(e)
249
250 let runningAgents = 0
251 try {
252 runningAgents = (await $.agent.list()).filter(agent => agent.status === 'running').length
253 } catch {
254 runningAgents = 0
255 }
256 if (runningAgents > 0) return next(e)
257
258 const { Box, Button, Text } = $.ui.resolve(e)
259 const fill = Math.max(0, Math.min(10, Math.round((percent ?? 0) / 10)))
260 const pixels = pixelRows(FLOPPY).map(runs => (
261 <Box key="floppy-row" flexDirection="row">
262 {runs.map((cell: Run) => (
263 <Text color={cell.color} backgroundColor={cell.backgroundColor}>
264 {cell.text}
265 </Text>
266 ))}
267 </Box>
268 ))
269
270 return (
271 <Box flexDirection="column">
272 <Box flexDirection="row">
273 <Box key="title">
274 <Text color={PALETTE.e} bold>
275 SAVE POINT ▸ safe to /compact
276 </Text>
277 </Box>
278 <Text color={PALETTE.d}> QUICKSAVE</Text>
279 </Box>
280 <Box flexDirection="row">
281 <Box flexDirection="column">{pixels}</Box>
282 <Box flexDirection="column" marginLeft={2}>
283 <Box flexDirection="row">
284 <Text color={PALETTE.l}>CONTEXT </Text>
285 <Text color={PALETTE.g}>{'█'.repeat(fill)}</Text>
286 <Text color={PALETTE.d}>{'░'.repeat(10 - fill)}</Text>
287 <Text color={PALETTE.w}> {String(percent)}%</Text>
288 </Box>
289 <Text color={PALETTE.l}>IDLE · NOTHING RUNNING</Text>
290 {current.isSaving === true ? (
291 <Box key="saving">
292 <Text color={PALETTE.y}>SAVING… HOLD STILL</Text>
293 </Box>
294 ) : current.savedSlot === undefined ? (
295 <Text color={PALETTE.l}>SAVES GOAL, BRANCH, RULES, NEXT STEP</Text>
296 ) : (
297 <Box key="saved">
298 <Text color={PALETTE.e}>SAVED ★ SLOT {String(current.savedSlot)}</Text>
299 </Box>
300 )}
301 <Text color={PALETTE.d}>/quickload HANDS IT BACK</Text>
302 </Box>
303 </Box>
304 <Box flexDirection="row">
305 <Button key="save" label="SAVE" hotkey="s" variant="primary" onPress={async () => { await saveFromBand($, settings) }} />
306 <Text> </Text>
307 <Button key="save-compact" label="SAVE + COMPACT" hotkey="c" onPress={() => saveAndCompact($, settings)} />
308 </Box>
309 </Box>
310 )
311 })
312}
313hooks/pixels.ts 73 lines1/**
2 * Half-block pixel art: a sprite is a grid of palette keys ('.' is
3 * transparent); two pixel rows fold into one text row, the top pixel as the
4 * `▀`'s color and the bottom one as its background.
5 */
6
7/** PICO-8, keyed by one letter. */
8export const PALETTE = {
9 k: '#000000', // black
10 n: '#1D2B53', // navy
11 m: '#7E2553', // plum
12 g: '#008751', // green: quicksave's signature
13 b: '#AB5236', // brown
14 d: '#5F574F', // dark grey
15 l: '#C2C3C7', // light grey
16 w: '#FFF1E8', // white
17 r: '#FF004D', // red
18 o: '#FFA300', // orange
19 y: '#FFEC27', // yellow
20 e: '#00E436', // lime
21 u: '#29ADFF', // blue
22 v: '#83769C', // lavender
23 i: '#FF77A8', // pink
24 c: '#FFCCAA', // peach
25} as const
26
27export type Run = { text: string; color?: string; backgroundColor?: string }
28
29const colorOf = (key: string | undefined): string | undefined =>
30 key === undefined || key === '.' ? undefined : (PALETTE as Record<string, string>)[key]
31
32const cellOf = (top: string | undefined, bottom: string | undefined): Run => {
33 if (top && bottom) return { text: '▀', color: top, backgroundColor: bottom }
34 if (top) return { text: '▀', color: top }
35 if (bottom) return { text: '▄', color: bottom }
36
37 return { text: ' ' }
38}
39
40const sameStyle = (a: Run, b: Run): boolean => a.color === b.color && a.backgroundColor === b.backgroundColor
41
42/** Folds a grid into text rows of styled runs, merging neighbours of one style. */
43export const pixelRows = (grid: readonly string[]): Run[][] => {
44 const rows: Run[][] = []
45 const width = Math.max(0, ...grid.map(line => line.length))
46 for (let y = 0; y < grid.length; y += 2) {
47 const runs: Run[] = []
48 for (let x = 0; x < width; x += 1) {
49 const cell = cellOf(colorOf(grid[y]?.[x]), colorOf(grid[y + 1]?.[x]))
50 const last = runs[runs.length - 1]
51 if (last && last.text[0] === cell.text && sameStyle(last, cell)) last.text += cell.text
52 else if (last && last.text[0] === ' ' && cell.text === ' ') last.text += ' '
53 else runs.push({ ...cell })
54 }
55 rows.push(runs)
56 }
57
58 return rows
59}
60
61/** Lines sprites up left to right, `gap` transparent columns apart, tops aligned. */
62/** The save icon, 8x8: a green floppy with a grey shutter and a white label. */
63export const FLOPPY: readonly string[] = [
64 'egllllg.',
65 'gglkllgg',
66 'ggllllgg',
67 'gggggggg',
68 'gwwwwwwg',
69 'gwddddwg',
70 'gwwwwwwg',
71 'gggggggg',
72]
73hooks/save.ts 227 lines1/**
2 * quicksave's pure logic: the prompt that asks for a save, reading the reply,
3 * the slot list, and the text the model reads back after a compaction.
4 */
5
6export const PLUGIN = 'quicksave'
7
8/** How many saves are kept per session. */
9export const MAX_SLOTS = 5
10
11/** The first words of every note quicksave hands the model; also how a note already present is recognised. */
12export const NOTE_MARKER = 'QUICKSAVE'
13
14export type Save = {
15 goal: string
16 step: string
17 cwd: string
18 branch: string
19 links: string[]
20 decisions: string[]
21 rules: string[]
22 next: string
23 /** The reply's own words, kept when it was not readable JSON. */
24 notes?: string
25}
26
27export type Slot = {
28 /** When it was saved, in ms since the epoch. */
29 at: number
30 /** What made it: a compaction trigger (`manual`, `auto`, `plugin`), `command` or `band`. */
31 trigger: string
32 save: Save
33}
34
35export const SAVE_PROMPT = [
36 'QUICKSAVE: the conversation is about to be compacted. Write a save point so the work can continue from it afterwards.',
37 'Reply with one JSON object and nothing else, with exactly these keys:',
38 '{',
39 ' "goal": "the overall task, in one sentence",',
40 ' "step": "what is being done right now",',
41 ' "cwd": "the working directory",',
42 ' "branch": "the git branch, or empty",',
43 ' "links": ["every open PR, ticket, URL or file path mentioned that still matters"],',
44 ' "decisions": ["each decision made so far, with its reason"],',
45 ' "rules": ["each rule or preference the user set in this session, in their words"],',
46 ' "next": "the very next step"',
47 '}',
48 'Use only what this conversation says. Leave a field empty rather than guess. Call no tools.',
49].join('\n')
50
51const MAX_FIELD = 2_000
52const MAX_ITEMS = 30
53
54const clip = (text: string): string => (text.length > MAX_FIELD ? `${text.slice(0, MAX_FIELD)}…` : text)
55
56const asText = (value: unknown): string => (typeof value === 'string' ? clip(value.trim()) : '')
57
58const asList = (value: unknown): string[] => {
59 if (typeof value === 'string') return value.trim() === '' ? [] : [clip(value.trim())]
60 if (!Array.isArray(value)) return []
61 return value
62 .filter((item): item is string => typeof item === 'string' && item.trim() !== '')
63 .map(item => clip(item.trim()))
64 .slice(0, MAX_ITEMS)
65}
66
67const EMPTY_SAVE: Save = { goal: '', step: '', cwd: '', branch: '', links: [], decisions: [], rules: [], next: '' }
68
69/** The first `{ ... }` object in the text that parses, or undefined. */
70const firstObject = (text: string): Record<string, unknown> | undefined => {
71 for (let start = text.indexOf('{'); start !== -1; start = text.indexOf('{', start + 1)) {
72 for (let end = text.lastIndexOf('}'); end > start; end = text.lastIndexOf('}', end - 1)) {
73 try {
74 const value: unknown = JSON.parse(text.slice(start, end + 1))
75 if (value !== null && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>
76 } catch {
77 // a shorter span may parse
78 }
79 }
80 }
81 return undefined
82}
83
84/** Reads the model's reply as a save; a reply that is not JSON is kept whole as `notes`. */
85export const parseSave = (text: string): Save => {
86 const object = firstObject(text)
87 if (object === undefined) return { ...EMPTY_SAVE, notes: clip(text.trim()) }
88 return {
89 goal: asText(object.goal),
90 step: asText(object.step),
91 cwd: asText(object.cwd),
92 branch: asText(object.branch),
93 links: asList(object.links),
94 decisions: asList(object.decisions),
95 rules: asList(object.rules),
96 next: asText(object.next),
97 }
98}
99
100/** Puts a slot first and keeps the newest `cap`. */
101export const pushSlot = (slots: readonly Slot[], slot: Slot, cap = MAX_SLOTS): Slot[] => [slot, ...slots].slice(0, cap)
102
103/** How many sessions keep their slots in the store; older sessions' slots are removed. */
104export const MAX_SESSIONS = 20
105
106/** Moves a session id to the front of the kept list; answers the list to keep and the ids to drop. */
107export const touchSession = (ids: unknown, id: string, cap = MAX_SESSIONS): { keep: string[]; drop: string[] } => {
108 const known = Array.isArray(ids) ? ids.filter((item): item is string => typeof item === 'string' && item !== id) : []
109 const all = [id, ...known]
110 return { keep: all.slice(0, cap), drop: all.slice(cap) }
111}
112
113/** Reads stored slots, dropping anything that is not one. */
114export const readSlots = (value: unknown): Slot[] =>
115 Array.isArray(value)
116 ? value.filter(
117 (item): item is Slot =>
118 item !== null && typeof item === 'object' && typeof item.at === 'number' && typeof item.save === 'object',
119 )
120 : []
121
122/** `/quickload [n]`: 1 is the newest; empty means 1. */
123export const pickSlot = (slots: readonly Slot[], argument: string): Slot | undefined => {
124 const word = argument.trim()
125 if (word === '') return slots[0]
126 if (!/^\d+$/.test(word)) return undefined
127 return slots[Number(word) - 1]
128}
129
130/** `2026-10-03 12:04`, in UTC so it reads the same everywhere. */
131export const stamp = (at: number): string => new Date(at).toISOString().slice(0, 16).replace('T', ' ')
132
133const headline = (save: Save): string => save.goal || save.step || (save.notes ?? '').split('\n')[0] || '(no goal recorded)'
134
135/** `/quicksave list`. */
136export const slotList = (slots: readonly Slot[]): string => {
137 if (slots.length === 0) return 'QUICKSAVE ▸ NO SAVES YET. /quicksave makes one now.'
138 const lines = slots.map((slot, index) => {
139 const mark = index === 0 ? '★' : '●'
140 const words = headline(slot.save)
141 const short = words.length > 60 ? `${words.slice(0, 59)}…` : words
142 return `${index + 1} ${mark} ${stamp(slot.at)} ${slot.trigger.toUpperCase()} ${short}`
143 })
144 return ['QUICKSAVE ▸ SLOTS (newest first)', ...lines, '/quickload [n] hands slot n back to the model.'].join('\n')
145}
146
147const listSection = (title: string, items: readonly string[]): string[] =>
148 items.length === 0 ? [] : [`${title}:`, ...items.map(item => `- ${item}`)]
149
150const lineSection = (title: string, text: string): string[] => (text === '' ? [] : [`${title}: ${text}`])
151
152/** The user-role note the model reads after a compaction or `/quickload`. */
153export const saveNote = (slot: Slot, number: number): string => {
154 const { save } = slot
155 const place = save.cwd === '' ? '' : save.branch === '' ? save.cwd : `${save.cwd} (branch ${save.branch})`
156 return [
157 `${NOTE_MARKER} (slot ${number}, saved ${stamp(slot.at)} UTC, ${slot.trigger}). This is the save point written before the conversation was compacted. Continue the task from it; the user's rules below still apply.`,
158 ...lineSection('GOAL', save.goal),
159 ...lineSection('CURRENT STEP', save.step),
160 ...lineSection('WORKING DIRECTORY', place || save.branch),
161 ...listSection('OPEN LINKS', save.links),
162 ...listSection('DECISIONS', save.decisions),
163 ...listSection('RULES THE USER SET', save.rules),
164 ...lineSection('NEXT STEP', save.next),
165 ...(save.notes ? ['NOTES:', save.notes] : []),
166 ].join('\n')
167}
168
169/** A note `saveNote` wrote: the marker followed by its `(slot n, saved ...` header, not any text that merely starts with the word. */
170export const isSaveNote = (text: string): boolean => new RegExp(`^${NOTE_MARKER} \\(slot \\d+, saved `).test(text)
171
172const mdList = (title: string, items: readonly string[]): string[] =>
173 items.length === 0 ? [] : ['', `## ${title}`, ...items.map(item => `- ${item}`)]
174
175const mdLine = (title: string, text: string): string[] => (text === '' ? [] : ['', `## ${title}`, text])
176
177/** The mirror written when `writeFile` is on. */
178export const saveMarkdown = (slot: Slot, sessionId: string): string => {
179 const { save } = slot
180 return [
181 `# Quicksave ${sessionId}`,
182 '',
183 `Saved ${stamp(slot.at)} UTC (${slot.trigger}).`,
184 ...mdLine('Goal', save.goal),
185 ...mdLine('Current step', save.step),
186 ...mdLine('Working directory', save.cwd),
187 ...mdLine('Branch', save.branch),
188 ...mdList('Open links', save.links),
189 ...mdList('Decisions', save.decisions),
190 ...mdList('Rules the user set', save.rules),
191 ...mdLine('Next step', save.next),
192 ...mdLine('Notes', save.notes ?? ''),
193 '',
194 ].join('\n')
195}
196
197type Message = { role: 'user' | 'assistant'; text: string; toolUses: readonly unknown[] }
198
199/**
200 * The compacted conversation with this save as its last user message. Any
201 * earlier save note the compaction kept (an older compaction's, or a
202 * `/quickload` of an older slot) is dropped, so the model continues from this
203 * save and reads exactly one.
204 */
205export const withNote = <M extends Message>(messages: readonly M[], note: string): (M | Message)[] => [
206 ...messages.filter(message => !(message.role === 'user' && isSaveNote(message.text))),
207 { role: 'user', text: note, toolUses: [] },
208]
209
210/** The compaction instructions `[ SAVE + COMPACT ]` passes, so the summary keeps the save too. */
211export const compactInstructions = (note: string): string =>
212 `Keep this save point in the summary, word for word where you can:\n${note}`
213
214export type BandInputs = { percent: number | undefined; threshold: number; isWorking: boolean; runningAgents: number }
215
216/** The save-point band: at or above the threshold, no turn running, no background agent running. */
217export const shouldShowBand = ({ percent, threshold, isWorking, runningAgents }: BandInputs): boolean =>
218 percent !== undefined && percent >= threshold && !isWorking && runningAgents === 0
219
220export const percentOption = (configured: unknown): number =>
221 typeof configured === 'number' && Number.isFinite(configured) ? Math.min(100, Math.max(1, Math.round(configured))) : 70
222
223export const writeFileOption = (configured: unknown): boolean => configured === true
224
225/** Why a fork gave no save, for the toast. */
226export const failureText = (reason: string): string => `QUICKSAVE ✕ SAVE FAILED (${reason}). Compacting anyway.`
227types/index.d.ts 15 lines1export type QuicksaveBand = {
2 /** The context window's fill from the last measurement, 0 to 100; absent until one (and after a compaction). */
3 percent?: number
4 /** The slot number the band last saved to, shown as SAVED ★ SLOT n until the next measurement. */
5 savedSlot?: number
6 /** True while a save's fork runs; the band shows SAVING and its buttons join that save. */
7 isSaving?: boolean
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 quicksave: { band: QuicksaveBand }
13 }
14}
15