SLOPSHOPPER

shake

Shrink a long session by replacing old tool results with saved-file placeholders during compaction

newcommandtoastpromptprocess
v0.1.0no licenseupdated 2026-10-08serhiichuk/claude-shake
A shopper browsing a rack in a slop shop
README

shake

A Claude Code 2.1.293 mod (function-hook plugin). It shrinks a long session while you keep working in it: during a compaction it replaces old, large tool results with a <persisted-output> placeholder that points to a saved copy. Every other message stays as it is. After the compaction it writes anchors on the compaction boundary in the session file, so a later --resume loads the shaken history without duplicates.

The plugin name is shake, because Claude Code reserves names that start with claude-.

Decisions

The design decisions live in the agents repo, not here. These links assume both repos are checked out side by side under ~/Repos.

docs/research/ holds frozen copies of the reports and reviews the ADR cites. Update the ADR, not the copies.

How it works

  • Trigger. After each main-loop turn that ends with an answer, the mod reads the context usage. If the usage is at or above the threshold, it calls $.session.compact({ instructions: 'shake' }). The engine also raises session.compact for its own auto-compaction, and the mod handles that too. For a manual test, type /compact shake.
  • Shake (session.compact hook). The mod acts on /compact shake, on its own trigger and on auto. It skips precompute, subagent and fork transcripts, and a plain /compact.
  • The newest tool output (16k tokens) stays as it is.
  • An older tool result larger than 2,000 characters is saved to <project dir>/<session id>/shake/<tool_use_id>.txt (file mode 600, directory mode 700, never overwritten). Its text becomes the native placeholder: Output too large (…). Full output saved to: <path> and a 500-character preview that ends on a code point.
  • A result that starts with [shaken:, <persisted-output> or <truncated-output> is never shaken again.
  • Assistant messages, tool_use blocks, tool_use_ids and is_error flags stay. Each message that the mod did not change keeps its engine handle.
  • If the shake frees less than 15% of the used context, the mod does not shake. On auto, native compaction runs instead. On /compact shake and on the mod's own trigger, the compaction is skipped with a one-line reason.
  • Anchor repair (/shake-repair). The shake hook queues this command. It runs when the session is idle. An idle prompt.submit runs the same repair after a load, after a failed queue and once after a timeout. It also waits for a repair that is still running, so no turn starts during a rewrite. A prompt waits at most about 3 s plus the rewrite. After a refusal, or after a second timeout, the mod gives up on that boundary and shows a toast. The shake records its time in <project dir>/<session id>/shake/last-shake.json. The repair:
  • finds the last compact_boundary. It stops if the boundary already has anchors, if a native summary follows it, or if it is not the one boundary at or after the recorded shake time (another plugin or a native compaction wrote it);
  • waits until the new boundary and every tool result of the shaken transcript are on disk, and the file size is stable for 250 ms (timeout 3 s);
  • checks that the rows after the boundary form one chain;
  • adds compactMetadata.preservedMessages = {anchorUuid: <boundary uuid>, uuids: <rows after the boundary, in order>} to the boundary line and changes no other byte;
  • writes a temp file in the same directory, copies whole lines appended meanwhile, checks the inode, calls fsync and renames. A hard-link backup (<session>.jsonl.shake-bak.<ns>) exists until the rename succeeded. If rows reached the old file during the rename, the repair copies them to the new file and keeps that backup. After each repair, only the newest backup of the session stays.

On resume, the loader (g1r) then relinks the listed rows and drops the pre-boundary originals from memory. The rows stay in the file.

Requirements

  • Claude Code 2.1.293 (the mod API is early access and changes between releases).
  • python3 on PATH. The file work runs in bin/shake_io.py, because the mod environment has no rename, fsync or file modes.

Install

Install from GitHub:

claude plugin marketplace add serhiichuk/claude-shake
claude plugin install shake@claude-shake

To get a new version, run claude plugin marketplace update claude-shake.

Load the plugin from a local clone for one session:

claude --plugin-dir /path/to/claude-shake

$.session.compact() works only in the interactive terminal UI. In -p and SDK sessions, only /compact shake and the engine's auto-compaction reach the hook.

Config

All values are in CONFIG in hooks/shake.ts. There are no other options.

KeyDefaultMeaning
thresholdPercent60Context usage that starts a shake after a turn
keepTokens16000Newest tool output that is never shaken
tokensPerChar0.34Token estimate for one character
minResultChars2000Smallest result that is shaken
floorShare0.15Least share of the used context a shake must free

With --plugin-dir, a saved edit reloads the module in the running session.

Tools for a manual test

  • bin/shake-fork <session id or path>: copies a session to a new id in the same project directory. Only the sessionId values change; cost-state rows are left out. The source file is read only, and its sha256 is checked before and after. It prints the claude --resume command with a cd to the cwd of the first row: Claude Code finds a session through the project directory of the directory the session started in, not the last cwd. If that cwd does not map to the project directory name, it prints a warning.
  • bin/shake-check <session.jsonl>: prints rows, boundaries with or without anchors, and what the loader would load: duplicate message blocks, tool_use ids and tool_result ids, dangling parents, thinking blocks, shaken results and saved files present. It mirrors the 2.1.293 loader on a best-effort basis.

Manual test

Each step that sends a prompt to the model costs API tokens.

  1. Pick a long session with large old tool outputs (more than 100k tokens of context). Note its session id.
  2. Run bin/shake-fork <session id>. Expected: source sha256 (unchanged), a fork: path and a cd … && claude --resume <new id> --plugin-dir … command.
  3. Run bin/shake-check <fork path>. Keep the output as the baseline.
  4. Run the printed command. Expected: the session opens with no plugin error line. claude --debug shows the plugin's lines in the debug log.
  5. Run /context and note the used tokens.
  6. Type /compact shake. Expected: a toast shake: replaced N old tool results and a "Compacted" notice, or one line that says the shake is below the floor.
  7. Wait 1 second. The queued /shake-repair runs by itself.
  8. In a second terminal, run bin/shake-check <fork path>. Expected:
  9. the last boundary has "anchors": true;
  10. loader.relink is anchors applied;
  11. all duplicate_* counts are 0;
  12. saved_files_missing is 0.
  13. Run /context. Expected: fewer used tokens than in step 5.
  14. Send one prompt, for example Reply with ok. Expected: a normal answer.
  15. Exit, then run the resume command again. Expected: /context shows about the same usage as in step 9, and bin/shake-check still reports 0 duplicates.
  16. Optional: ask the model to read one saved file from a placeholder. Expected: it reads the full original output.
  17. Optional, for the automatic trigger: set thresholdPercent to a value under the current usage, save the file, and send one prompt. Expected: after the answer, the same toast and notice as in step 6.
  18. Remove the fork when done: the fork .jsonl file and the <project dir>/<new id>/ directory.

Measured

One live test on 2.1.293 (the user's run, an Opus fork of a 551k-token session):

  • /compact shake: context 551k -> 202.7k tokens. The first request after --resume: 217.7k tokens.
  • The boundary was anchored. bin/shake-check reported 0 duplicates.
  • The same file without anchors: 81 duplicate tool_use ids on the path for files of 5 MiB or less, and only 38 loaded rows on the path above 5 MiB.

Known limits

  • $.session.compact() is available only in the TUI. Headless sessions refuse it; there, use /compact shake or rely on auto-compaction.
  • A changed user message loses its engine handle. Its attachment rows and its toolUseResult record are not written again (measured: 34 to 23 attachment rows in the research run).
  • Each shake writes a compact_boundary and shows a "Compacted" notice.
  • The server drops the thinking after the first changed message, and the prompt cache is written again once per shake.
  • The anchor repair was measured in one live resume only (see Measured).
  • Above 5 MiB, the engine's own transcript GC deletes the unlisted pre-boundary rows from the file, as it does after a native compaction. /rewind to a point before the shake may then fail.
  • The repair refuses (and leaves the file as it is) when the rows after the boundary do not form one chain, for example after a rewind.
  • Storage v5 is not supported. Without a plain session file, the repair does nothing.

Checks

claude plugin validate .
claude plugin test .          # selection, placeholder and preview unit checks
python3 test/test_repair.py   # save, repair and fork on a copied fixture

test/test_repair.py takes a hook-compacted session file as its argument. The default is the e3 file of the live-shake research run. The fixture is only copied.

Type-check: after one load with --plugin-dir, the engine writes .claude-plugin/types/, and tsc -p . works. Before that, use the tsconfig.json from the header of the types file that the plugin-authoring skill writes.

Source 2 files
hooks/register.ts 132 lines
1import type { EngineInterface, Register, ToolResultSummary } from 'claude-code'
2
3import {
4  CONFIG,
5  SHAKE_INSTRUCTIONS,
6  estimatedTokens,
7  freedTokens,
8  replaceResults,
9  selectOld,
10} from './shake'
11
12type RepairExpectation = { since: number; toolIds: string[] }
13
14const REPAIR_TIMEOUT_SECONDS = 3
15const REPAIR_TIMEOUT_RETRIES = 1
16
17const runHelper = async ($: EngineInterface, args: string[], stdin?: string) =>
18  $.process.run(['python3', `${$.plugin.root}/bin/shake_io.py`, ...args], { stdin, timeoutMs: 60_000 })
19
20const saveOriginals = async (
21  $: EngineInterface,
22  since: number,
23  results: readonly ToolResultSummary[],
24): Promise<Map<string, string>> => {
25  const items = results.map(result => ({ id: result.tool_use_id, text: result.text }))
26  const run = await runHelper($, ['save', await $.session.id()], JSON.stringify({ since, items }))
27  if (run.exitCode !== 0) throw new Error('shake could not save the original tool results', { cause: run })
28  return new Map(Object.entries(JSON.parse(run.stdout) as Record<string, string>))
29}
30
31let isCompacting = false
32let isRepairPending = true
33let expectation: RepairExpectation | undefined
34let repairing: Promise<void> | undefined
35let retryAboveTokens = 0
36let timeoutRetries = 0
37
38const settleRepair = ($: EngineInterface, exitCode: number): void => {
39  if (exitCode === 2 && timeoutRetries < REPAIR_TIMEOUT_RETRIES) {
40    timeoutRetries += 1
41    return
42  }
43  isRepairPending = false
44  expectation = undefined
45  timeoutRetries = 0
46  if (exitCode !== 0) $.ui.toast('shake: anchor repair gave up; a resume may load duplicates (see the debug log)')
47}
48
49const runRepair = async ($: EngineInterface): Promise<void> => {
50  const expected = expectation
51  const args = ['repair', await $.session.id(), '--timeout', String(REPAIR_TIMEOUT_SECONDS)]
52  if (expected) args.push('--expect', JSON.stringify(expected))
53  const run = await runHelper($, args).catch(error => {
54    $.ui.log(`repair helper failed: ${String(error)}`, { to: 'debug' })
55    return undefined
56  })
57  if (run) $.ui.log(`repair exit ${run.exitCode}: ${run.stdout.trim()} ${run.stderr.trim()}`, { to: 'debug' })
58  if (expectation === expected) settleRepair($, run?.exitCode ?? 3)
59}
60
61const repair = ($: EngineInterface): Promise<void> =>
62  (repairing ??= runRepair($)
63    .catch(() => settleRepair($, 3))
64    .finally(() => {
65      repairing = undefined
66    }))
67
68export const register: Register = on => {
69  on('session.start', async ($, e, next) => {
70    await $.command.register({
71      name: 'shake-repair',
72      description: 'shake: write anchors on the last shake boundary in the session file',
73    })
74    return next(e)
75  })
76
77  on('command.run', { command: 'shake-repair' }, async $ => {
78    await repair($)
79    return { text: '' }
80  })
81
82  on('prompt.submit', async ($, e, next) => {
83    if (!e.turnId && (isRepairPending || repairing)) await repair($)
84    return next(e)
85  })
86
87  on('turn.complete', async ($, e, next) => {
88    const completed = await next(e)
89    if (e.agentId || e.reason !== 'answer' || isCompacting) return completed
90    isCompacting = true
91    void (async () => {
92      const { context } = await $.session.usage()
93      const tokens = context.tokens ?? 0
94      if ((context.percent ?? 0) < CONFIG.thresholdPercent || tokens < retryAboveTokens) return
95      const compacted = await $.session.compact({ instructions: SHAKE_INSTRUCTIONS })
96      retryAboveTokens = compacted.skip === undefined ? 0 : tokens * (1 + CONFIG.floorShare)
97    })()
98      .catch(error => $.ui.log(`auto shake failed: ${String(error)}`, { to: 'debug' }))
99      .finally(() => {
100        isCompacting = false
101      })
102    return completed
103  })
104
105  on('session.compact', async ($, e, next) => {
106    const isOurs = e.instructions === SHAKE_INSTRUCTIONS
107    if (e.agentId || e.trigger === 'precompute' || !(isOurs || e.trigger === 'auto')) return next(e)
108    const since = await $.clock.now()
109    const old = selectOld(e.messages)
110    const used = (await $.session.usage()).context.tokens ?? estimatedTokens(e.messages)
111    const freed = freedTokens(old)
112    if (freed < CONFIG.floorShare * used) {
113      if (!isOurs) return next(e)
114      return { skip: `shake: a shake frees about ${Math.round(freed)} tokens, below the floor` }
115    }
116    const savedPaths = await saveOriginals($, since, old)
117    if (savedPaths.size === 0) return isOurs ? { skip: 'shake: no tool result could be saved' } : next(e)
118    const messages = replaceResults(e.messages, savedPaths)
119    expectation = {
120      since,
121      toolIds: messages.flatMap(message => (message.toolResults ?? []).map(result => result.tool_use_id)),
122    }
123    isRepairPending = true
124    timeoutRetries = 0
125    void $.command.run({ command: 'shake-repair' }).catch(() => {
126      isRepairPending = true
127    })
128    $.ui.toast(`shake: replaced ${savedPaths.size} old tool results`)
129    return { messages }
130  })
131}
132
hooks/shake.ts 93 lines
1import type { SessionMessage, ToolResultSummary } from 'claude-code'
2
3export const CONFIG = {
4  thresholdPercent: 60,
5  keepTokens: 16_000,
6  tokensPerChar: 0.34,
7  minResultChars: 2_000,
8  floorShare: 0.15,
9}
10
11export type Config = typeof CONFIG
12
13export const SHAKE_INSTRUCTIONS = 'shake'
14
15const PREVIEW_CHARS = 500
16const PLACEHOLDER_OVERHEAD_CHARS = PREVIEW_CHARS + 300
17const SHAKEN_PREFIXES = ['[shaken:', '<persisted-output>', '<truncated-output>']
18
19export const isShaken = (text: string): boolean => {
20  const head = text.trimStart()
21  return SHAKEN_PREFIXES.some(prefix => head.startsWith(prefix))
22}
23
24const resultsOf = (messages: readonly SessionMessage[]): ToolResultSummary[] =>
25  messages.flatMap(message => message.toolResults ?? [])
26
27export const selectOld = (messages: readonly SessionMessage[], config: Config = CONFIG): ToolResultSummary[] => {
28  const keepChars = config.keepTokens / config.tokensPerChar
29  const old: ToolResultSummary[] = []
30  let newerChars = 0
31  for (const result of resultsOf(messages).reverse()) {
32    if (isShaken(result.text)) continue
33    const isInKeepWindow = newerChars < keepChars
34    newerChars += result.text.length
35    if (!isInKeepWindow && result.text.length > config.minResultChars) old.push(result)
36  }
37  return old.reverse()
38}
39
40export const freedTokens = (old: readonly ToolResultSummary[], config: Config = CONFIG): number =>
41  old.reduce((sum, result) => sum + Math.max(0, result.text.length - PLACEHOLDER_OVERHEAD_CHARS), 0) * config.tokensPerChar
42
43export const estimatedTokens = (messages: readonly SessionMessage[], config: Config = CONFIG): number =>
44  messages.reduce(
45    (sum, message) =>
46      sum +
47      message.text.length +
48      message.toolUses.reduce((n, use) => n + JSON.stringify(use.input).length, 0) +
49      (message.toolResults ?? []).reduce((n, result) => n + result.text.length, 0),
50    0,
51  ) * config.tokensPerChar
52
53const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff
54
55export const preview = (text: string, maxChars: number = PREVIEW_CHARS): { text: string; hasMore: boolean } => {
56  if (text.length <= maxChars) return { text, hasMore: false }
57  const newline = text.lastIndexOf('\n', maxChars - 1)
58  let cut = newline > maxChars / 2 ? newline : maxChars
59  if (isHighSurrogate(text.charCodeAt(cut - 1))) cut -= 1
60  return { text: text.slice(0, cut), hasMore: true }
61}
62
63const formatSize = (chars: number): string => {
64  if (chars < 1024) return `${chars} bytes`
65  if (chars < 1024 * 1024) return `${Number((chars / 1024).toFixed(1))}KB`
66  return `${Number((chars / (1024 * 1024)).toFixed(1))}MB`
67}
68
69export const placeholder = (path: string, text: string): string => {
70  const cut = preview(text)
71  return (
72    `<persisted-output>\nOutput too large (${formatSize(text.length)}). Full output saved to: ${path}\n\n` +
73    `Preview (first ${formatSize(PREVIEW_CHARS)}):\n${cut.text}${cut.hasMore ? '\n...\n' : '\n'}</persisted-output>`
74  )
75}
76
77export const replaceResults = (
78  messages: readonly SessionMessage[],
79  savedPaths: ReadonlyMap<string, string>,
80): SessionMessage[] =>
81  messages.map(message => {
82    if (!message.toolResults?.some(result => savedPaths.has(result.tool_use_id))) return message
83    const { handle: _handle, ...rest } = message
84    return {
85      ...rest,
86      toolResults: message.toolResults.map(result => {
87        const path = savedPaths.get(result.tool_use_id)
88        if (path === undefined) return result
89        return { tool_use_id: result.tool_use_id, text: placeholder(path, result.text), isError: result.isError }
90      }),
91    }
92  })
93