A bar above the prompt showing how full the chat's context is (green under 60%, amber 60-80%, red above 80%), with an H button and /handoff to save a handoff…

A Claude Code mod that shows how full the current chat's context is and gives you a one-click handoff to a fresh chat, without losing what you were doing.
Based on Anthropic's token-weather sample (Apache-2.0). See CHANGES.md for what changed.
A bar above the message box:
● 72% context · getting full 144k / 200k ▁▂▃▅▆▇ [ H Handoff ]
| Context used | Colour | Means |
|---|---|---|
| under 60% | green | plenty of room |
| 60–80% | amber | getting full |
| above 80% | red | hand off soon |
The numbers are the same as the status line's ($.session.usage()), updated after each turn.
h, or type /handoff)..claude/handoff.md in the current project: plain English, under 300 words, with Goal, What's done, Decisions made, Key files, Next steps. The bar shows "handoff saved"..claude/handoff-used.md, so the chat after that starts clean.From the rai-claude-mods marketplace (see the main README):
claude plugin install context-handoff@rai-claude-mods --scope user
Then run /reload-plugins in an open session.
Move-Item / mv), because mods cannot rename files.Apache License 2.0. Modified from Anthropic's token-weather sample; the original copyright notice is kept in hooks/context-handoff.mjs and the license text is in LICENSE.
hooks/context-handoff.mjs 302 lines1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3// Modified 2026 by Rapple AI: green/amber/red context bar and a handoff note
4// carried from one chat to the next. See CHANGES.md.
5//
6// Context Handoff: how full the chat's context is, above the prompt, and a
7// one-click handoff to a fresh chat.
8//
9// turn.complete: after each main-loop turn, read the context window's fill
10// from $.session.usage() (the same figures the status line shows) and keep
11// the last HISTORY readings.
12// session.start: take a first reading. If the project has a handoff note from
13// the last 24 hours, written before this chat began, load it and rename it to
14// handoff-used.md so it loads only once.
15// prompt.compose: while a note is loaded, carry it in the system prompt.
16// ui.render (AbovePrompt): one line: the fill in green, amber or red, the
17// tokens used of the window, a chart of recent turns, and an H button.
18// command.run (/handoff) and the H button: ask Claude to write the note.
19// tool.call (Write, Edit): notice when the note is saved.
20//
21// The host reads on(...) and $.noun.method(...) from source, so they are
22// spelled literally, and helpers that take $ are top-level functions.
23
24import { atom, read, update } from "claude-code";
25
26const HISTORY = 12;
27const BARS = "▁▂▃▄▅▆▇█";
28const DAY_MS = 24 * 60 * 60 * 1000;
29const NOTE = ".claude/handoff.md";
30const USED = ".claude/handoff-used.md";
31const AMBER = "#FFB000";
32
33const loaded = atom({ plugin: "context-handoff", key: "loaded" }, null);
34const askedAt = atom({ plugin: "context-handoff", key: "askedAt" }, null);
35const savedAt = atom({ plugin: "context-handoff", key: "savedAt" }, null);
36
37// Readings: { tokens, window, percent }, oldest first.
38let readings = [];
39// The project the session runs in, with forward slashes.
40let cwd = "";
41
42export function register(on) {
43 on("session.start", async ($, e, next) => {
44 const result = await next(e);
45 cwd = e.cwd.replace(/\\/g, "/").replace(/\/$/, "");
46 readings = [];
47 try {
48 await $.command.register({
49 name: "handoff",
50 description: "Write a handoff note to .claude/handoff.md so the next chat in this project picks up where this one left off",
51 });
52 } catch {
53 // offered as /context-handoff:handoff instead
54 }
55 await takeReading($);
56 await loadHandoff($, cwd);
57 return result;
58 });
59
60 on("turn.complete", async ($, e, next) => {
61 const result = await next(e);
62 if (e.agentId) {
63 return result;
64 }
65 await takeReading($);
66 return result;
67 });
68
69 on("prompt.compose", async ($, e, next) => {
70 const composed = await next(e);
71 const note = await read($, loaded);
72 if (!note) {
73 return composed;
74 }
75 return {
76 ...composed,
77 sections: [...composed.sections, { id: "context-handoff:note", text: noteSection(note), scope: "session" }],
78 };
79 });
80
81 // A command hook holds the turn, so the request goes out once it returns.
82 on("command.run", { command: ["handoff", "context-handoff:handoff"] }, ($, e) => {
83 requestHandoffLater($, cwd).catch(() => {});
84 return { text: `Asking Claude to write the handoff note to ${NOTE}…` };
85 }).catch(() => ({ text: "context-handoff: could not ask for the note. Ask Claude to write .claude/handoff.md." }));
86
87 on("tool.call", { tool: ["Write", "Edit"] }, async ($, e, next) => {
88 const result = await next(e);
89 const path = String(e.file_path ?? "").replace(/\\/g, "/");
90 if (!result.deny && !result.isError && path.endsWith(`/${NOTE}`)) {
91 // only a status line: never let it touch the tool's own result
92 await markSaved($).catch(() => {});
93 }
94 return result;
95 }).catch(($, e, next) => next(e));
96
97 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
98 if (e.hasSurvey) {
99 return next(e);
100 }
101 const elements = $.ui.resolve(e);
102 const status = {
103 note: await read($, loaded),
104 asked: await read($, askedAt),
105 saved: await read($, savedAt),
106 };
107 return band($, elements, e.bodyColumns ?? 80, status);
108 });
109}
110
111async function takeReading($) {
112 try {
113 const { context } = await $.session.usage();
114 if (!context || !context.window) {
115 return;
116 }
117 const tokens = context.tokens ?? 0;
118 const percent = Math.round(context.percent ?? (tokens / context.window) * 100);
119 // The session.start reading is 0 before any response; drop it once real readings arrive.
120 readings = readings.filter((r) => r.tokens > 0);
121 readings.push({ tokens, window: context.window, percent });
122 if (readings.length > HISTORY) {
123 readings = readings.slice(-HISTORY);
124 }
125 $.ui.invalidate("ui.render");
126 } catch {
127 // No reading this turn; the band keeps the last one.
128 }
129}
130
131// Loads a note the last chat in this project left, once.
132async function loadHandoff($, dir) {
133 // a reload of the mod in the same chat: the note is already in
134 if (!dir || (await read($, loaded))) {
135 return;
136 }
137 const path = `${dir}/${NOTE}`;
138 try {
139 if (!(await $.fs.exists(path))) {
140 return;
141 }
142 const stat = await $.fs.stat(path);
143 const now = await $.clock.now();
144 const { startedAt } = await $.session.usage();
145 // A note saved during this chat is for the next one; one over a day old is stale.
146 if (stat.mtimeMs >= startedAt || now - stat.mtimeMs > DAY_MS) {
147 return;
148 }
149 const text = String(await $.fs.read(path)).trim();
150 if (!text) {
151 return;
152 }
153 await update($, loaded, () => ({ text, writtenAt: stat.mtimeMs }));
154 await markUsed($, dir, text);
155 $.ui.toast(`Loaded the handoff note from ${ago(now - stat.mtimeMs)} ago`);
156 $.ui.invalidate("ui.render");
157 } catch {
158 // No note this time.
159 }
160}
161
162// Renames handoff.md to handoff-used.md. Mods cannot rename files, so a small
163// system command does it; if that fails, keep a used copy and empty the note,
164// which never loads (an empty note is skipped).
165async function markUsed($, dir, text) {
166 const from = `${dir}/${NOTE}`;
167 const to = `${dir}/${USED}`;
168 const isWindows = (await $.env.get("OS")) === "Windows_NT";
169 const argv = isWindows
170 ? ["powershell", "-NoProfile", "-NonInteractive", "-Command", `Move-Item -LiteralPath '${psQuote(from)}' -Destination '${psQuote(to)}' -Force`]
171 : ["mv", "-f", from, to];
172 try {
173 const ran = await $.process.run(argv, { cwd: dir });
174 if (ran.exitCode === 0 && !(await $.fs.exists(from))) {
175 return;
176 }
177 } catch {
178 // fall back below
179 }
180 await $.fs.write(to, `${text}\n`);
181 await $.fs.write(from, "");
182}
183
184async function markSaved($) {
185 const now = await $.clock.now();
186 await update($, savedAt, () => now);
187 $.ui.invalidate("ui.render");
188}
189
190// The session's folder is read now, not at start: a chat can move to another
191// project after it starts, and the note belongs where the chat is.
192async function requestHandoff($, startDir) {
193 const dir = await currentDir($, startDir);
194 const now = await $.clock.now();
195 await update($, askedAt, () => now);
196 $.ui.invalidate("ui.render");
197 await $.prompt.submit({ text: handoffPrompt(dir), asUser: true });
198}
199
200async function requestHandoffLater($, dir) {
201 try {
202 await $.clock.sleep(150);
203 await requestHandoff($, dir);
204 } catch (error) {
205 $.ui.toast(`context-handoff: could not ask for the note (${String(error).slice(0, 80)})`);
206 }
207}
208
209async function currentDir($, fallback) {
210 try {
211 return (await $.session.cwd()).replace(/\\/g, "/").replace(/\/$/, "") || fallback;
212 } catch {
213 return fallback;
214 }
215}
216
217function handoffPrompt(dir) {
218 return [
219 "Write a handoff note so a fresh chat can pick up this work without me explaining it again.",
220 `Save it to ${dir}/${NOTE} (create the .claude folder if needed, and replace any older note).`,
221 "",
222 "Rules:",
223 "- Plain English, under 300 words, specific: names, numbers, file paths, commands. No secrets.",
224 "- Use exactly these five headings, in this order: ## Goal, ## What's done, ## Decisions made, ## Key files, ## Next steps",
225 "- Key files: real paths, each with a few words on why it matters.",
226 "- Next steps: numbered, the very next action first.",
227 "",
228 "After saving it, reply with one line: where it is and how many words it has.",
229 ].join("\n");
230}
231
232function noteSection(note) {
233 return [
234 "# Handoff from the previous chat in this project",
235 `The person ended their last chat here with the handoff note below (saved ${stamp(note.writtenAt)}). It is the context for this chat: when they ask what you were working on, or to carry on, answer from it without asking them to explain again. The note has been renamed to ${USED}, so it loads only once.`,
236 "",
237 note.text,
238 ].join("\n");
239}
240
241// Green under 60%, amber from 60 to 80%, red above 80%.
242function levelFor(percent) {
243 if (percent < 60) return { color: "green", word: "" };
244 if (percent <= 80) return { color: AMBER, word: " · getting full" };
245 return { color: "red", word: " · hand off soon" };
246}
247
248function band($, elements, columns, status) {
249 const { Box, Text, Button } = elements;
250 const now = readings[readings.length - 1] ?? { tokens: 0, window: 0, percent: 0 };
251 const level = levelFor(now.percent);
252 const parts = [Text({ color: level.color, bold: true, children: `● ${now.percent}% context${level.word}` })];
253 if (now.window) {
254 parts.push(Text({ dimColor: true, children: ` ${short(now.tokens)} / ${short(now.window)}` }));
255 }
256 if (columns >= 70 && readings.length > 1) {
257 parts.push(Text({ color: level.color, children: ` ${chart()}` }));
258 }
259 const line = statusLine(status);
260 if (line) {
261 parts.push(Text({ dimColor: true, children: ` ${line}` }));
262 }
263 parts.push(Text({ children: " " }));
264 parts.push(Button({ key: "handoff", label: "H Handoff", hotkey: "h", onPress: () => requestHandoff($, cwd) }));
265 return Box({ flexDirection: "row", paddingX: 1, children: parts });
266}
267
268function statusLine({ note, asked, saved }) {
269 if (saved !== null && (asked === null || saved >= asked)) return `handoff saved to ${NOTE}`;
270 if (asked !== null) return "writing handoff…";
271 if (note) return "loaded last chat's handoff";
272 return "";
273}
274
275// Bars scale to the busiest reading shown, so growth shows at any fill level.
276function chart() {
277 const top = Math.max(...readings.map((r) => r.tokens), 1);
278 const bars = readings.map((r) => BARS[Math.min(BARS.length - 1, Math.floor((r.tokens / top) * (BARS.length - 1)))]);
279 return bars.join("");
280}
281
282function short(n) {
283 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
284 if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}k`;
285 return String(n);
286}
287
288function ago(ms) {
289 const minutes = Math.max(1, Math.round(ms / 60000));
290 if (minutes < 60) return `${minutes} min`;
291 const hours = Math.round(minutes / 60);
292 return `${hours} hour${hours === 1 ? "" : "s"}`;
293}
294
295function stamp(ms) {
296 return `${new Date(ms).toISOString().slice(0, 16).replace("T", " ")} UTC`;
297}
298
299function psQuote(path) {
300 return path.replace(/'/g, "''");
301}
302types/index.d.ts 15 lines1// The handoff note this session loaded from the last chat, if any.
2export type HandoffLoaded = { text: string; writtenAt: number } | null
3
4declare module 'claude-code' {
5 interface PluginState {
6 'context-handoff': {
7 // the note loaded at session start, carried into the system prompt
8 loaded: HandoffLoaded
9 // when this session last asked Claude for a note, and when one was saved
10 askedAt: number | null
11 savedAt: number | null
12 }
13 }
14}
15