SLOPSHOPPER

cache-keeper

Таймер прогрева кэша, заполнение контекста, лимиты и кнопка переноса сессии

newbandtoasttimer
v0.1.0no licenseupdated 2026-10-07pewdredd/claude-code-mods/cache-keeper
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-keeper
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 ● кэш тёплый 60м │ контекст 97k │ 5ч 31% │ сессия $0.42 h: перенести сессию ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● кэш тёплый 60м │ контекст 97k │ 5ч 31% │ сессия $0.42 h: перенести сессию ⟨Claude Code's own drawing⟩
README

Моды для Claude Code

Пять модов - маленьких плагинов, которые меняют интерфейс Claude Code: показывают кэш и лимиты, прячут личное на записи экрана, рисуют прогресс цели и не дают двум чатам править один файл вслепую.

Из ролика на канале @amir_aktay.

Установка

В Claude Code:

/plugin marketplace add pewdredd/claude-code-mods
/plugin install cache-keeper@aktay-mods

Остальные ставятся так же, меняется только имя: collision-guard, goal-meter, next-steps, recording-mode. Можно поставить любые по отдельности.

Что делает каждый мод

МодЧто делаетКак пользоваться
cache-keeperНад полем ввода: сколько осталось жить кэшу промпта (60 минут), заполнение контекста, лимиты 5 часов и недели, цена сессии. За 5 минут до остывания кэша - уведомление. Кнопка «перенести сессию»: Claude пишет сводку сессии, потом кнопка «Очистить и продолжить» сама делает /clear и отправляет сводку в чистый чат. Пока включён режим записи, здесь же горит красный «● REC»работает сам
next-stepsПосле ответа - три кнопки «что дальше», одним нажатием отправляют следующий запросработает сам, /next-steps - вкл/выкл
recording-modeПока пишешь экран, прячет в транскрипте почты, ключи, телефоны и суммы. Claude при этом видит всё как есть. Метку «● REC» рисует cache-keeper - ставь их вместе/record on, /record off
goal-meterРазбивает цель на шаги, отдаёт агенту и показывает полоску прогресса. После каждого ответа отмечает готовые шаги/goal-meter <цель>, /goal-meter off
collision-guardЕсли файл в последние 30 минут правил другой чат Claude Code - останавливает правку и просит агента спросить тебя. Повторная правка проходитработает сам

Что важно знать

  • Next Steps и Goal Meter после каждого ответа делают один короткий запрос к модели. Он идёт через кэш, но лимиты всё равно немного расходует.
  • Recording Mode прячет только то, что нарисовано на экране. В сам чат и в файлы идут настоящие данные.
  • Таймер Cache Keeper рассчитан на подписку, где кэш живёт час. На API по умолчанию кэш живёт 5 минут - поменяй CACHE_TTL_MS в cache-keeper/hooks/register.tsx.

Свой мод

Опиши словами, что мод должен показывать и когда, и попроси Claude Code его собрать. Любой мод отсюда - хороший образец: три файла в папке плагина и один register.tsx.

Source 2 files
hooks/register.tsx 168 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, SessionRateLimit } from 'claude-code'
3
4import type { CacheKeeperGauge } from '../types'
5
6// Cache Keeper: сколько осталось жить кэшу промпта, насколько забит контекст,
7// сколько съедено лимитов, и кнопка «перенести сессию» в новый чат.
8// Claude Code на подписке держит кэш час; на API по умолчанию 5 минут
9const CACHE_TTL_MS = 60 * 60 * 1000
10const WARN_MS = 5 * 60 * 1000
11
12const lastTurnAt = atom({ plugin: 'cache-keeper', key: 'lastTurnAt' } as const, null)
13const now = atom({ plugin: 'cache-keeper', key: 'now' } as const, 0)
14const gauge = atom({ plugin: 'cache-keeper', key: 'gauge' } as const, null)
15// флаг мода recording-mode: REC рисуем здесь, чтобы он стоял сразу под строкой кэша
16const handoff = atom({ plugin: 'cache-keeper', key: 'handoff' } as const, null)
17const isRecording = atom({ plugin: 'recording-mode', key: 'isOn' } as const, false)
18
19const HANDOFF_MARK = 'ПЕРЕНОС СЕССИИ'
20const HANDOFF =
21  'Подготовь перенос сессии. Файлов не создавай: пришли перенос текстом прямо в ответе. ' +
22  `Первая строка ответа - ровно «${HANDOFF_MARK}», дальше разделы: цель, что уже сделано, ` +
23  'какие файлы менялись, на чём остановились, следующие 3 шага, важные решения. ' +
24  'Пиши так, чтобы новый чат продолжил работу, прочитав только этот текст.'
25
26// ответ - перенос, если метка стоит в самом начале (модель может добавить «## » или «**»)
27function isHandoff(text: string): boolean {
28  return text.slice(0, 80).includes(HANDOFF_MARK)
29}
30
31function percentOf(limits: SessionRateLimit[], kind: string): number | null {
32  return limits.find(l => l.kind === kind)?.percentUsed ?? null
33}
34
35function kilo(n: number): string {
36  return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
37}
38
39function mmss(ms: number): string {
40  const s = Math.max(0, Math.round(ms / 1000))
41  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
42}
43
44export const register: Register = on => {
45  let isWarned = false
46
47  on('session.start', async ($, e, next) => {
48    $.clock.every(5000, async () => {
49      const t = await $.clock.now()
50      await update($, now, () => t)
51      const last = await read($, lastTurnAt)
52      const left = last === null ? null : CACHE_TTL_MS - (t - last)
53      if (left !== null && left <= WARN_MS && !isWarned) {
54        isWarned = true
55        $.ui.toast('Cache Keeper: кэш остынет через 5 минут - продолжи, сожми (/compact) или перенеси сессию')
56      }
57    })
58    return next(e)
59  })
60
61  on('session.measure', async ($, e, next) => {
62    const g: CacheKeeperGauge = {
63      contextPercent: e.context.percent ?? null,
64      contextTokens: e.context.tokens ?? null,
65      fiveHour: percentOf(e.rateLimits, 'five_hour'),
66      sevenDay: percentOf(e.rateLimits, 'seven_day'),
67      costUsd: e.cost?.usd ?? null,
68    }
69    await update($, gauge, () => g)
70    return next(e)
71  })
72
73  on('turn.complete', async ($, e, next) => {
74    if (!e.agentId) {
75      const t = await $.clock.now()
76      isWarned = false
77      await update($, lastTurnAt, () => t)
78      await update($, now, () => t)
79      // последний ответ Claude - перенос? Тогда запоминаем его текст, и над полем
80      // появится кнопка «Очистить и продолжить». Любой другой ответ кнопку убирает
81      const reply = (await $.session.messages()).findLast(m => m.role === 'assistant')?.text ?? ''
82      await update($, handoff, () => (isHandoff(reply) ? reply : null))
83    }
84    return next(e)
85  })
86
87  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
88    const g = await read($, gauge)
89    const last = await read($, lastTurnAt)
90    const isRec = await read($, isRecording)
91    const handoffText = await read($, handoff)
92    if (e.props.hasSurvey || (g === null && last === null && !isRec && handoffText === null)) return next(e)
93
94    const { Box, Button, Text } = $.ui.resolve(e)
95    const left = last === null ? null : CACHE_TTL_MS - ((await read($, now)) - last)
96
97    // точка светофором: зелёная - кэш тёплый, жёлтая - скоро остынет, красная - остыл
98    const isWarm = e.props.isWorking || left === null || left > WARN_MS
99    const dot = isWarm ? 'success' : left > 0 ? 'warning' : 'error'
100    const cache = isWarm
101      ? left === null || e.props.isWorking ? 'кэш тёплый' : `кэш тёплый ${Math.ceil(left / 60000)}м`
102      : left > 0 ? `кэш остывает ${mmss(left)}` : 'кэш остыл'
103
104    const limits = [
105      g?.fiveHour != null ? `5ч ${g.fiveHour}%` : null,
106      g?.sevenDay != null ? `неделя ${g.sevenDay}%` : null,
107    ].filter(Boolean)
108    const parts = [
109      g?.contextTokens != null
110        ? `контекст ${kilo(g.contextTokens)}`
111        : g?.contextPercent != null ? `контекст ${Math.round(g.contextPercent)}%` : null,
112      limits.length ? limits.join(' · ') : null,
113      g?.costUsd != null ? `сессия $${g.costUsd.toFixed(2)}` : null,
114    ].filter((p): p is string => p !== null)
115
116    const mine = (
117      <Box flexDirection="column">
118        <Box flexDirection="row" justifyContent="space-between" width="100%">
119          <Box flexDirection="row" flexShrink={1}>
120            <Text color={dot}>● {cache}</Text>
121            {parts.map(p => (
122              <Text dimColor wrap="truncate">{`  │  ${p}`}</Text>
123            ))}
124          </Box>
125          {handoffText === null ? (
126            <Button
127              key="handoff"
128              plain
129              hotkey="h"
130              label="перенести сессию"
131              onPress={() => $.prompt.submit({ text: HANDOFF })}
132            />
133          ) : (
134            <Button
135              key="clear-continue"
136              plain
137              hotkey="c"
138              label="Очистить и продолжить"
139              onPress={async () => {
140                // текст держим в переменной: мод при /clear не перезагружается,
141                // а session.start после /clear не приходит - ловить его незачем
142                const text = handoffText
143                await update($, handoff, () => null)
144                await $.command.run({ command: 'clear' })
145                await $.prompt.submit({ text: `Продолжаем работу из прошлой сессии. Перенос:\n\n${text}` })
146              }}
147            />
148          )}
149        </Box>
150        {isRec && (
151          <Text>
152            <Text color="error" bold>● REC</Text>
153            <Text dimColor>  личное скрыто</Text>
154          </Text>
155        )}
156      </Box>
157    )
158
159    // место над полем ввода общее: дорисовываем под собой строки других модов
160    return (
161      <Box flexDirection="column">
162        {mine}
163        {await next(e)}
164      </Box>
165    )
166  })
167}
168
types/index.d.ts 22 lines
1export type CacheKeeperGauge = {
2  contextPercent: number | null
3  contextTokens: number | null
4  fiveHour: number | null
5  sevenDay: number | null
6  costUsd: number | null
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'cache-keeper': {
12      lastTurnAt: number | null
13      now: number
14      gauge: CacheKeeperGauge | null
15      // текст переноса из последнего ответа Claude; null - кнопку «Очистить и продолжить» не показываем
16      handoff: string | null
17    }
18    // чужое значение, только чтение: включён ли режим записи у recording-mode
19    'recording-mode': { isOn: boolean }
20  }
21}
22