SLOPSHOPPER

context-handoff

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 токенов…

newbandcommandtoastprocesstimer
★ 1v1.0.2MITupdated 2026-10-26ArtyaEXE/claude-mods/context-handoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-handoff
› 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 › /handoff ⎿ context-handoff: Preparing the handoff: the agent writes the file, then the chat clears and a fresh agent continues. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

context-handoff

Moves a long session to a fresh one through a handoff file when the context grows past 350k / 600k / 800k tokens.

English · Русский

English

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.

What it touches

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.

Settings (/config)

  • language: auto (default; follows LANG, else the Windows UI language or regional format), ru or en.

Install

/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.

Source 2 files
hooks/register.tsx 218 lines
1import { 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}
218
types/index.d.ts 17 lines
1export 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