At 350k/600k/800k context tokens offers a move: the agent writes a handoff file, the chat clears, a fresh agent continues / На 350k/600k/800k токенов…

Moves a long session to a fresh one through a handoff file when the context grows past 350k / 600k / 800k tokens.
Past a few hundred thousand tokens an agent starts to drift. At 350k, 600k and 800k this mod shows a bar above the prompt: Move, Later or Not in this session. Move asks the agent to write .handoff/handoff-<date>.md in the project root (goal, what is done, where it stopped, decisions, pitfalls, next steps), then runs /clear and asks the fresh agent to read the file and continue. If the file does not appear, nothing is cleared.
/handoff does the same on demand.
Reads the session's context size. On Move it submits two prompts and runs /clear; it never clears the chat unless the handoff file exists. Adds the /handoff command.
/config)language: auto (default; follows LANG, else the Windows UI language or regional format), ru or en./plugin install context-handoff --marketplace ArtyaEXE/claude-mods
Part of claude-mods. MIT license.
Переносит длинную сессию в чистую через handoff-файл, когда контекст перерастает 350k / 600k / 800k токенов.
После нескольких сотен тысяч токенов агент начинает «плыть». На 350k, 600k и 800k мод показывает плашку над полем ввода: Переехать, Позже или Не предлагать в этой сессии. «Переехать» просит агента записать .handoff/handoff-<дата>.md в корне проекта (цель, что сделано, где остановились, решения, грабли, следующие шаги), затем выполняет /clear и просит нового агента прочитать файл и продолжить. Если файл не появился, чат не очищается.
/handoff делает то же самое вручную.
Читает размер контекста сессии. По «Переехать» отправляет два промпта и выполняет /clear; без handoff-файла чат не очищает. Добавляет команду /handoff.
/config)language: auto (по умолчанию; по LANG, иначе по языку интерфейса или региональному формату Windows), ru или en./plugin install context-handoff --marketplace ArtyaEXE/claude-mods
Часть claude-mods. Лицензия MIT.
hooks/register.tsx 218 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HandoffOffer, HandoffPhase } from '../types'
5
6type Lang = 'ru' | 'en'
7
8// `ru` / `en` as set; `auto`: the locale variables first, then Windows' UI language or regional format.
9async function resolveLang($: EngineInterface, setting: unknown): Promise<Lang> {
10 if (setting === 'ru' || setting === 'en') return setting
11 const env = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
12 if (env) return env.toLowerCase().startsWith('ru') ? 'ru' : 'en'
13 try {
14 const ran = await $.process.run(
15 ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '(Get-UICulture).Name; (Get-Culture).Name'],
16 { timeoutMs: 10_000 },
17 )
18 if (ran.exitCode === 0 && /^ru\b/im.test(ran.stdout)) return 'ru'
19 } catch {
20 // Not Windows: English.
21 }
22
23 return 'en'
24}
25
26const THRESHOLDS = [350_000, 600_000, 800_000]
27
28const offer = atom({ plugin: 'context-handoff', key: 'offer' } as const, null as HandoffOffer | null)
29// The highest threshold already offered (or declined) in this context.
30const passed = atom({ plugin: 'context-handoff', key: 'passed' } as const, 0)
31const phase = atom({ plugin: 'context-handoff', key: 'phase' } as const, { kind: 'idle' } as HandoffPhase)
32
33const kTokens = (n: number) => `${Math.round(n / 1000)}k`
34
35const stamp = (ms: number) => {
36 const d = new Date(ms)
37 const p = (n: number) => String(n).padStart(2, '0')
38
39 return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`
40}
41
42const STRINGS = {
43 ru: {
44 writePrompt: (path: string) => `Контекст этой сессии разросся, переезжаем в чистую сессию. Запиши handoff-файл для следующего агента: ${path}
45
46Это единственное, что увидит новый агент, поэтому пиши так, чтобы он продолжил без вопросов:
471. Цель: что мы делаем и зачем, в 2-3 предложениях.
482. Что уже сделано: ключевые изменения, с полными путями файлов.
493. Где остановились: текущий шаг и что именно осталось.
504. Решения и договорённости с пользователем, которые нельзя потерять (и почему так решили).
515. Грабли: что не сработало, ошибки, особенности окружения.
526. Следующие шаги: конкретный список по порядку.
537. Важные команды, пути, URL, имена.
54
55Только факты, без воды. Создай папку, если её нет. Больше ничего не делай, после записи коротко ответь "handoff готов".`,
56 resumePrompt: (path: string) =>
57 `Ты продолжаешь работу предыдущей сессии. Прочитай handoff-файл ${path} целиком и продолжи с места, где там указано, что мы остановились. Сначала в 2-3 строках скажи, как ты понял текущий шаг.`,
58 clearFailed: (error: string, path: string) =>
59 `context-handoff: не смог очистить чат (${error}). Сделай /clear вручную и попроси прочитать ${path}`,
60 noFile: (path: string) => `context-handoff: файл ${path} не появился, переезд отменён`,
61 command: 'Переехать в чистую сессию через handoff-файл прямо сейчас',
62 started: 'Готовлю handoff: агент запишет файл, потом чат очистится и новый агент продолжит.',
63 bandTitle: (tokens: string) => `Контекст ${tokens} токенов.`,
64 bandBody: ' Дальше агент начинает «плыть». Переехать в чистую сессию через handoff-файл?',
65 move: 'Переехать',
66 later: 'Позже',
67 never: 'Не предлагать в этой сессии',
68 },
69 en: {
70 writePrompt: (path: string) => `This session's context has grown large, so we are moving to a fresh session. Write a handoff file for the next agent: ${path}
71
72It is the only thing the new agent will see, so write it so that it can continue without asking:
731. Goal: what we are doing and why, in 2-3 sentences.
742. Done so far: the key changes, with full file paths.
753. Where we stopped: the current step and exactly what is left.
764. Decisions and agreements with the user that must not be lost (and why they were made).
775. Pitfalls: what did not work, errors, quirks of the environment.
786. Next steps: a concrete ordered list.
797. Important commands, paths, URLs, names.
80
81Facts only, no filler. Create the folder if it does not exist. Do nothing else; once written, reply briefly "handoff ready".`,
82 resumePrompt: (path: string) =>
83 `You are continuing the previous session's work. Read the handoff file ${path} in full and continue from where it says we stopped. First say in 2-3 lines how you understand the current step.`,
84 clearFailed: (error: string, path: string) =>
85 `context-handoff: could not clear the chat (${error}). Run /clear yourself and ask to read ${path}`,
86 noFile: (path: string) => `context-handoff: ${path} did not appear, the move is cancelled`,
87 command: 'Move to a fresh session through a handoff file right now',
88 started: 'Preparing the handoff: the agent writes the file, then the chat clears and a fresh agent continues.',
89 bandTitle: (tokens: string) => `Context at ${tokens} tokens.`,
90 bandBody: ' From here the agent starts to drift. Move to a fresh session through a handoff file?',
91 move: 'Move',
92 later: 'Later',
93 never: 'Not in this session',
94 },
95}
96
97let lang: Lang = 'en'
98
99async function startHandoff($: EngineInterface) {
100 const root = (await $.session.root()).replace(/\\/g, '/')
101 const path = `${root}/.handoff/handoff-${stamp(await $.clock.now())}.md`
102 await update($, offer, () => null)
103 // 'requested' until the prompt actually enters: a turn that ends before it must not cancel the move.
104 await update($, phase, () => ({ kind: 'requested', path }) as HandoffPhase)
105 const sent = await $.prompt.submit({ text: STRINGS[lang].writePrompt(path) })
106 if (sent.drop !== undefined) {
107 await update($, phase, () => ({ kind: 'idle' }) as HandoffPhase)
108 $.ui.toast(STRINGS[lang].noFile(path), { timeoutMs: 10000 })
109
110 return
111 }
112 // The writing turn may already be over by the time the submit resolves.
113 if (await $.fs.exists(path)) {
114 await update($, phase, () => ({ kind: 'moving', path }) as HandoffPhase)
115 $.clock.after(300, () => void move($, path))
116 } else {
117 await update($, phase, () => ({ kind: 'writing', path }) as HandoffPhase)
118 }
119}
120
121// Runs from a timer: /clear cannot run inside a hook the turn waits on.
122async function move($: EngineInterface, path: string) {
123 try {
124 await $.command.run({ command: 'clear', args: '' })
125 await update($, passed, () => 0)
126 await update($, phase, () => ({ kind: 'idle' }) as HandoffPhase)
127 await $.prompt.submit({ text: STRINGS[lang].resumePrompt(path) })
128 } catch (error) {
129 await update($, phase, () => ({ kind: 'idle' }) as HandoffPhase)
130 $.ui.toast(STRINGS[lang].clearFailed(String(error), path), { timeoutMs: 15000 })
131 }
132}
133
134export const register: Register = (on, options) => {
135 on('session.start', async ($, e, next) => {
136 lang = await resolveLang($, (options as { language?: unknown }).language)
137 await $.command.register({ name: 'handoff', description: STRINGS[lang].command })
138
139 return next(e)
140 })
141
142 // Submitted from a timer: a prompt submitted while the command itself is running gets lost.
143 on('command.run', { command: 'handoff' }, async $ => {
144 $.clock.after(300, () => void startHandoff($))
145
146 return { text: STRINGS[lang].started }
147 })
148
149 on('session.end', async ($, e, next) => {
150 if (e.reason === 'clear') {
151 await update($, passed, () => 0)
152 await update($, offer, () => null)
153 }
154
155 return next(e)
156 })
157
158 on('turn.complete', async ($, e, next) => {
159 const done = await next(e)
160 if (e.agentId !== undefined) return done
161
162 const now = await read($, phase)
163 if (now.kind === 'writing') {
164 if (await $.fs.exists(now.path)) {
165 await update($, phase, () => ({ kind: 'moving', path: now.path }) as HandoffPhase)
166 $.clock.after(300, () => void move($, now.path))
167 } else {
168 await update($, phase, () => ({ kind: 'idle' }) as HandoffPhase)
169 $.ui.toast(STRINGS[lang].noFile(now.path), { timeoutMs: 10000 })
170 }
171
172 return done
173 }
174 if (now.kind === 'moving' || now.kind === 'requested') return done
175
176 const tokens = (await $.session.usage()).context.tokens ?? 0
177 const already = await read($, passed)
178 const crossed = THRESHOLDS.filter(t => t <= tokens && t > already).at(-1)
179 if (crossed !== undefined) await update($, offer, () => ({ tokens, threshold: crossed }))
180
181 return done
182 })
183
184 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
185 const below = await next(e)
186 const shown = await read($, offer)
187 if (shown === null || e.props.hasSurvey || e.props.isWorking || e.props.view.agentId !== undefined) {
188 return below
189 }
190
191 const { Box, Button, Text } = $.ui.resolve(e)
192 const L = STRINGS[lang]
193 const later = async () => {
194 await update($, passed, () => shown.threshold)
195 await update($, offer, () => null)
196 }
197 const never = async () => {
198 await update($, passed, () => Number.MAX_SAFE_INTEGER)
199 await update($, offer, () => null)
200 }
201
202 return (
203 <Box flexDirection="column">
204 <Text>
205 <Text color="yellow">{L.bandTitle(kTokens(shown.tokens))}</Text>
206 {L.bandBody}
207 </Text>
208 <Box flexDirection="row" gap={1}>
209 <Button key="move" variant="primary" label={L.move} onPress={() => startHandoff($)} />
210 <Button key="later" label={L.later} onPress={later} />
211 <Button key="never" label={L.never} onPress={never} />
212 </Box>
213 {below}
214 </Box>
215 )
216 })
217}
218types/index.d.ts 17 lines1export type HandoffOffer = { tokens: number; threshold: number }
2export type HandoffPhase =
3 | { kind: 'idle' }
4 | { kind: 'requested'; path: string }
5 | { kind: 'writing'; path: string }
6 | { kind: 'moving'; path: string }
7
8declare module 'claude-code' {
9 interface PluginState {
10 'context-handoff': {
11 offer: HandoffOffer | null
12 passed: number
13 phase: HandoffPhase
14 }
15 }
16}
17