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

Пять модов - маленьких плагинов, которые меняют интерфейс 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 - останавливает правку и просит агента спросить тебя. Повторная правка проходит | работает сам |
CACHE_TTL_MS в cache-keeper/hooks/register.tsx.Опиши словами, что мод должен показывать и когда, и попроси Claude Code его собрать. Любой мод отсюда - хороший образец: три файла в папке плагина и один register.tsx.
hooks/register.tsx 168 lines1import { 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}
168types/index.d.ts 22 lines1export 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