Compact large conversations before a prompt rewrites an expired cache.

hooks/register.ts 266 lines1import { update } from "claude-code";
2import type { EngineInterface, Register, Timer } from "claude-code";
3import type { ColdCacheGuardTranscriptPath } from "../types/index";
4
5const MIB = 1024 * 1024;
6const transcriptPath = { plugin: "cold-cache-guard", key: "transcriptPath" } as const;
7const pendingPrompts = { plugin: "cold-cache-guard", key: "pendingPrompts" } as const;
8
9function isObject(value: unknown): value is Record<string, unknown> {
10 return value !== null && typeof value === "object" && !Array.isArray(value);
11}
12
13function requireString(value: unknown) {
14 if (typeof value !== "string") throw new Error("Expected a string");
15 return value;
16}
17
18function nonnegativeNumber(value: unknown) {
19 if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
20 throw new Error("Expected a nonnegative number");
21 }
22 return value;
23}
24
25type AssistantEntry = Record<string, unknown> & { message: Record<string, unknown> };
26
27function qualifies(entry: unknown): entry is AssistantEntry {
28 return isObject(entry) && entry.type === "assistant" && entry.isSidechain !== true &&
29 isObject(entry.message) && entry.message.model !== undefined &&
30 entry.message.model !== "<synthetic>" && entry.message.usage !== undefined;
31}
32
33function usageOf(entry: AssistantEntry) {
34 const usage = entry.message.usage;
35 if (!isObject(usage)) throw new Error("Invalid usage");
36 return usage;
37}
38
39function tokenCount(usage: Record<string, unknown>, field: string) {
40 return usage[field] === undefined ? 0 : nonnegativeNumber(usage[field]);
41}
42
43function writeTtl(entry: AssistantEntry) {
44 const usage = usageOf(entry);
45 const creation = usage.cache_creation === undefined ? {} : usage.cache_creation;
46 if (!isObject(creation)) throw new Error("Invalid cache creation");
47 const hour = tokenCount(creation, "ephemeral_1h_input_tokens");
48 const short = tokenCount(creation, "ephemeral_5m_input_tokens");
49 if (hour > 0) return 3600;
50 if (short > 0) return 300;
51 if (tokenCount(usage, "cache_creation_input_tokens") > 0) return 3600;
52 return null;
53}
54
55export function scanTail(text: string, now: number) {
56 let last: AssistantEntry | null = null;
57 let compactAfterLast = false;
58 let ttl = null;
59 let end = text.length;
60 while (end > 0) {
61 const start = text.lastIndexOf("\n", end - 1) + 1;
62 const line = text.slice(start, end);
63 end = start - 1;
64 let entry: unknown;
65 try {
66 entry = JSON.parse(line);
67 } catch {
68 continue;
69 }
70 if (!last && isObject(entry) && entry.type === "system" && entry.subtype === "compact_boundary") {
71 compactAfterLast = true;
72 }
73 if (!qualifies(entry)) continue;
74 if (!last) last = entry;
75 ttl = writeTtl(entry);
76 if (ttl !== null) break;
77 }
78 if (!last) return null;
79 const usage = usageOf(last);
80 const tokens = nonnegativeNumber([
81 "input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens", "output_tokens",
82 ].reduce((total, field) => total + tokenCount(usage, field), 0));
83 const timestamp = Date.parse(requireString(last.timestamp));
84 if (!Number.isFinite(timestamp)) throw new Error("Invalid timestamp");
85 const model = requireString(last.message.model);
86 if (!model) throw new Error("Missing model");
87 return { tokens, model, idle: (now - timestamp) / 1000, ttl: ttl ?? 3600, compactAfterLast };
88}
89
90// $.fs has no ranged reads. Stage the bounded window in chunks below its 4 MiB cap.
91const stageTail = `set -eu
92directory=$(/usr/bin/mktemp -d "\${TMPDIR:-/tmp}/cold-cache-guard.XXXXXXXX")
93trap '/bin/rm -rf "$directory"' 0
94/bin/dd if="$1" of="$directory/window" bs=1048576 skip="$(($2 / 1048576))" count="$((($2 % 1048576 + $3 + 1048575) / 1048576))" 2>/dev/null
95/usr/bin/tail -c "+$(($2 % 1048576 + 1))" "$directory/window" | /usr/bin/head -c "$3" | /usr/bin/split -b 1048576 - "$directory/chunk-"
96printf '%s' "$directory"
97trap - 0`;
98
99export async function readTail($: EngineInterface, transcript: string, size: number, limit: number) {
100 const start = Math.max(0, size - limit);
101 let text;
102 if (size <= 4 * MIB) {
103 text = await $.fs.read(transcript);
104 } else {
105 const staged = await $.process.run([
106 "/bin/sh", "-c", stageTail, "cold-cache-guard", transcript, String(start), String(size - start),
107 ]);
108 if (staged.exitCode !== 0) throw new Error("Could not stage transcript tail");
109 const directory = staged.stdout;
110 try {
111 const entries = (await $.fs.list(directory)).filter((entry) => entry.name.startsWith("chunk-"));
112 entries.sort((left, right) => left.name.localeCompare(right.name));
113 const bytes = new Uint8Array(size - start);
114 let offset = 0;
115 for (const entry of entries) {
116 const { base64 } = await $.fs.read(`${directory}/${entry.name}`, { as: "bytes" });
117 const chunk = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
118 bytes.set(chunk, offset);
119 offset += chunk.length;
120 }
121 text = new TextDecoder().decode(bytes.subarray(0, offset));
122 } finally {
123 await $.process.run(["/bin/rm", "-rf", directory]);
124 }
125 }
126 if (!start) return text;
127 const newline = text.indexOf("\n");
128 return newline === -1 ? "" : text.slice(newline + 1);
129}
130
131async function readTranscript($: EngineInterface, transcript: string, now: number) {
132 const info = await $.fs.stat(transcript);
133 if (info.kind !== "file") throw new Error("Not a transcript file");
134 let result = scanTail(await readTail($, transcript, info.size, 4 * MIB), now);
135 if (!result) result = scanTail(await readTail($, transcript, info.size, 32 * MIB), now);
136 return result;
137}
138
139async function consumeForce($: EngineInterface) {
140 const home = await $.env.get("HOME");
141 if (!home) return false;
142 const file = `${home}/.cache/cold-cache-guard/force-once`;
143 if (!await $.fs.exists(file)) return false;
144 const result = await $.process.run(["/bin/rm", file]);
145 if (result.exitCode !== 0) throw new Error("Could not consume force-once");
146 return true;
147}
148
149export function formatIdle(seconds: number) {
150 const minutes = Math.max(0, Math.floor(seconds / 60));
151 const hours = Math.floor(minutes / 60);
152 if (hours >= 48) return `${Math.floor(hours / 24)}d ${hours % 24}h`;
153 if (hours) return `${hours}h ${minutes % 60}m`;
154 return `${minutes}m`;
155}
156
157export function formatTokens(tokens: number) {
158 return tokens >= 1000000 ? `~${(tokens / 1000000).toFixed(1)}M` : `~${Math.round(tokens / 1000)}k`;
159}
160
161function oneLine(reason: unknown) {
162 return String(reason instanceof Error ? reason.message : reason).replace(/[\r\n]+/g, " ");
163}
164
165export const register: Register = (on, options) => {
166 let schedule: (() => void) | null = null;
167 let timer: Timer | null = null;
168 let busy = false;
169 let requested = false;
170 const delivered = new Set<string>();
171
172 on("session.start", async ($, e, next) => {
173 const drain = async () => {
174 timer = null;
175 if (busy) return;
176 busy = true;
177 requested = false;
178 try {
179 const { value = [] } = await $.state.get(pendingPrompts);
180 const job = value[0];
181 if (!job) return;
182 try {
183 if (!job.compactionAttempted) {
184 await update($, pendingPrompts, (jobs) => (jobs ?? []).map((pending) =>
185 pending.id === job.id ? { ...pending, compactionAttempted: true } : pending));
186 // Headless (VS Code, SDK, -p) rejects $.session.compact() in Claude Code 2.1.310; /compact via $.command.run works (verified 26-10-2026).
187 if (e.isInteractive) {
188 const result = await $.session.compact();
189 if (result.skip !== undefined) {
190 $.ui.toast(`Compaction skipped: ${oneLine(result.skip)}; sending your prompt`);
191 }
192 } else {
193 await $.command.run({ command: "compact" });
194 }
195 }
196 } catch (error) {
197 $.ui.toast(`Compaction failed: ${oneLine(error)}; sending your prompt`);
198 } finally {
199 if (!delivered.has(job.id)) {
200 const result = await $.prompt.submit({
201 text: job.prompt.text,
202 attachments: job.prompt.attachments,
203 asUser: true,
204 });
205 if ("drop" in result) throw new Error(result.drop);
206 delivered.add(job.id);
207 }
208 let more = false;
209 await update($, pendingPrompts, (jobs) => {
210 const remaining = (jobs ?? []).filter((pending) => pending.id !== job.id);
211 more = remaining.length > 0;
212 return remaining;
213 });
214 delivered.delete(job.id);
215 if (more) timer = $.clock.after(0, drain);
216 }
217 } catch (error) {
218 try {
219 $.ui.toast(`Prompt still queued: ${oneLine(error)}; retrying automatically`);
220 } catch {}
221 timer = $.clock.after(1000, drain);
222 } finally {
223 busy = false;
224 if (requested && !timer) schedule?.();
225 }
226 };
227 schedule = () => {
228 requested = true;
229 if (!timer && !busy) timer = $.clock.after(0, drain);
230 };
231 if ((await $.state.get(pendingPrompts)).value?.length) schedule();
232 return next(e);
233 });
234
235 on("classic.SessionStart", async ($, e, next) => {
236 await $.state.set(transcriptPath, e.transcript_path);
237 return next(e);
238 });
239
240 on("prompt.submit", async ($, e, next) => {
241 try {
242 if (e.command || e.isMeta || e.turnId !== undefined ||
243 !["composer", "bridge", "sdk"].includes(e.origin.kind)) return next(e);
244 const forced = await consumeForce($);
245 const { value = null } = await $.state.get(transcriptPath);
246 const path: ColdCacheGuardTranscriptPath = value;
247 if (!path) return next(e);
248 const session = await readTranscript($, path, await $.clock.now());
249 if (!session || session.compactAfterLast) return next(e);
250 const margin = session.ttl === 3600 ? 120 : 30;
251 if (!forced && (session.idle <= session.ttl - margin || session.tokens < 150000)) return next(e);
252 // This build's plugin submit bridge drops media even when attachments are passed.
253 if (e.attachments?.length) {
254 $.ui.toast("Compaction unavailable for attached prompts in this build; sending your prompt with attachments");
255 return next(e);
256 }
257 if (!schedule) return next(e);
258 $.ui.toast(`Cache expired (idle ${formatIdle(session.idle)}, ${formatTokens(session.tokens)} tokens): compacting before your prompt`);
259 await update($, pendingPrompts, (jobs) => [...(jobs ?? []), { id: crypto.randomUUID(), prompt: e }]);
260 schedule();
261 return { drop: "Compacting first; your prompt follows automatically." };
262 } catch {}
263 return next(e);
264 }).catch(($, e, next) => next(e));
265};
266types/index.d.ts 11 lines1export type ColdCacheGuardTranscriptPath = string | null;
2
3declare module "claude-code" {
4 interface PluginState {
5 "cold-cache-guard": {
6 transcriptPath: ColdCacheGuardTranscriptPath;
7 pendingPrompts: { id: string; prompt: PromptSubmitInput; compactionAttempted?: boolean }[];
8 };
9 }
10}
11