SLOPSHOPPER

quicksave

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…

newbandcommandtoastmodel
v0.1.0MITupdated 2026-10-04pourya7/claude-code-mods/quicksave
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quicksave
› fix the failing auth test and add an audit log call ╭────────────────────────────────╮ │ quicksave │ ⏺ Read(src/auth.ts) │ QUICKSAVE ★ SLOT 1 SAVED 08:53 │ ⎿ Read 6 lines ╰────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /quicksave ⎿ quicksave: QUICKSAVE ★ SAVED TO SLOT 1 · (no goal recorded) ⎿ quicksave: /quickload hands it back to the model. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README
█▀█ █ █ █ █▀▀ █▄▀ █▀ ▄▀█ █ █ █▀▀
▀▀█ █▄█ █ █▄▄ █ █ ▄█ █▀█ ▀▄▀ ██▄   SAVE POINT ▸

/compact saves the goal, decision, rule and next step first, and the save comes back as the first message after the compaction

quicksave — a save point before compaction

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.

Install

/plugin marketplace add pourya7/claude-code-mods
/plugin install quicksave@claude-code-mods

How it behaves

  • Save before compaction. On every compaction of the main conversation (/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:
  • the goal
  • the current step
  • the working directory and branch
  • the open PRs, tickets and links mentioned
  • the decisions made
  • the rules you set in this session
  • the next step
  • Slots. The save goes into $.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.
  • Restore after compaction. Once the summary is written, quicksave adds the save as the last user-role message of the compacted conversation. The model's next request reads the summary and then the save, which starts with 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.
  • A failed save never blocks. If the fork gives no answer (an API error, an empty reply, or nothing to fork yet), compaction goes ahead as normal and a toast says QUICKSAVE ✕ SAVE FAILED (<reason>). Compacting anyway.
  • Save-point band. The band appears only when both of these are true:
  • the context window is at least warnAtPercent full (70% by default)
  • the session is idle, with no turn running and no subagent running
  • One save at a time. While a save is being taken, the band shows 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.
  • File mirror (opt-in). With writeFile on, each save is also written to <session cwd>/.claude/quicksave/<session-id>.md. It is off by default.

Commands

CommandWhat it does
/quicksaveSaves now, without compacting, into slot 1.
/quicksave listShows the slots, newest first: number, time (UTC), what made the save, and the goal.
/quickloadHands 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.

Configuration (userConfig)

FieldTypeDefaultMeaning
warnAtPercentnumber70Show the SAVE POINT band once the context window is this full (1 to 100) and the session is idle.
writeFilebooleanfalseAlso 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 UI

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.

Permissions

NetworkRuns processesFilesCalls a modelAuto-submits promptsData 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.

Limits

  • How the save comes back. After a compaction, the save is added to the compacted conversation that the 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 and subagents. A 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.
  • "Idle" is partial. The band counts a running turn and running subagents ($.agent.list). The mods API does not list background shells or monitors, so the band can show while one of those is running.
  • Cost and delay. Each save is one extra model request over the whole transcript, made just before compaction. The prompt cache usually serves most of it, but the compaction starts a little later.
  • The save is the model's own account. quicksave saves only what the model writes from the conversation. It does not read git or your files to check it.
  • Times are UTC.

Development

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.

Source 4 files
hooks/register.tsx 313 lines
1import { 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}
313
hooks/pixels.ts 73 lines
1/**
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]
73
hooks/save.ts 227 lines
1/**
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.`
227
types/index.d.ts 15 lines
1export 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