Keeps a big chat's prompt cache warm, warns before an expensive cold restart, shows every local chat on one board (/board), and hands a chat off to a fresh one…

Four mods for Claude Code. Mods are plugins that run inside Claude Code: they can draw on screen (a line above the prompt, a footer label, a side pane), add slash commands, and step in before Claude runs a tool. These four save money on big chats, keep private things off screen while you record, show how far along a /goal is, and stop two chats from editing the same file.
| Mod | Commands | What it does |
|---|---|---|
| Cache Keeper | /cache, /keepwarm, /board, /handoff | Shows whether a chat's prompt cache is warm or cold and what a cold restart would cost, keeps big chats warm, asks before an expensive cold send, shows every local chat on one board, and hands a chat off to a fresh one |
| Recording Mode | /rec, /rec strict, /rec off, /rec config | Masks keys, personal details, and business figures on screen and keeps private files closed while you record |
| Goal Meter | /goals | A progress bar for /goal built from Claude's own task plan, with an ETA and every chat's goal in one pane |
| Collision Guard | /guard | Asks before Claude edits a file another open chat changed in the last 30 minutes |
This is a continuation of Nate Herk's claude-code-mods (MIT), which is no longer developed. Everything up to the first commit is his; the rest of the history is ours. Cache Keeper grew the most, and Goal Meter has a bug fix. Recording Mode and Collision Guard are still as Nate wrote them. More mods can be added to this repo, because a repo like this is a plugin marketplace with one folder per mod.
claude --version. The mods work in the terminal and in the Desktop app's Code tab.claude plugin test in any folder: "no hooks module to load" means mods can load, and "served off" means they can't yet. Nothing needs reinstalling once they're on.Add this repo as a plugin marketplace:
claude plugin marketplace add jurczykpawel/claude-code-mods
Then install the mods you want, one command each:
claude plugin install cache-keeper@techskillacademy-mods
claude plugin install recording-mode@techskillacademy-mods
claude plugin install goal-meter@techskillacademy-mods
claude plugin install collision-guard@techskillacademy-mods
Then open a new chat. A mod loads when a chat starts, and /reload-plugins does not reload it.
Update: run claude plugin marketplace update techskillacademy-mods, then claude plugin update <mod>@techskillacademy-mods, then open a new chat. Installs are copies, so nothing changes until you update.
Remove: claude plugin uninstall <mod>@techskillacademy-mods.
Every request re-reads the whole chat. From the prompt cache that costs a tenth of the normal input price. Once the cache expires (after 5 minutes or 1 hour idle, depending on your setup), the next message writes the whole chat into the cache again at up to 2x the input price. On a 400k-token chat that one message can cost a few dollars, while keeping the cache warm costs cents.
● cache warm 42m │ ctx 412k │ rewrite ≈ $3.30 │ session $5.12. It turns yellow 5 minutes before the cache expires and shows a keep warm button. Your 5-hour and weekly plan limits show there too, yellow at 80% and red at 95%./keepwarm [hours|off]: keeps this chat's cache warm while you are away. It stops after N hours with no message from you (12 by default, up to 24); every message restarts that count. A small ping goes out shortly before it would expire. If a ping writes to the cache instead of reading it, keep warm turns itself off and tells you./board: every local chat in one pane, with chats waiting on you first, then working ones, then the ones whose cache cools soonest. Each row shows state, context size, cache, cost, and running agents, with a keep-warm button.handoff button (or /handoff) runs the bundled /session-handoff skill, saves the handoff, then clears the chat and sends the handoff as the fresh chat's first prompt. A short fresh chat is far cheaper than a huge one./cache stats: what keep warm cost against what cold restarts cost, over the last 30 days, shown only when you ask. Its first line is the version this chat loaded, so you can tell a chat that started before an update./cache compact: compares the price of compacting now with the price of carrying the long context, using your own measured numbers. /cache horizon auto|N sets how many turns ahead it counts./cache endcompact 200k|off: when keep warm is about to hit its time limit on a big chat, Cache Keeper compacts while the cache is still warm instead of letting it expire and paying for a full rewrite./cache idleguard on|off: Claude Code can compact a chat on its own just before the 1-hour cache expires. While keep warm is on and nobody has written for 30 minutes, the guard vetoes that, and /cache stats shows how many times it did./cache autocompact 300k|off: asks before the cache expires whether to compact a chat above that size. /cache reset puts every setting back to its default./cache shows status and settings: ttl 5|60|auto, guard on|off, big 150k, alerts on|off.For unattended agent sessions, set these variables in the session's environment before starting Claude Code:
CACHE_KEEPER_KEEPWARM_HOURS=24 CACHE_KEEPER_GUARD=off claude
CACHE_KEEPER_KEEPWARM_HOURS starts keep warm automatically for 1–72 hours, including on resume, clear, and fork. Every real message restarts that window; /keepwarm off stops it for the rest of that session. Missing or invalid values leave keep warm off. CACHE_KEEPER_GUARD=off sends cold messages without the cold-send dialog in this session only; other values use the saved guard setting. Neither variable changes saved settings. Manual /keepwarm still has its 24-hour maximum.
Prices come from the Claude API price list. On a subscription plan they show up as usage against your limits, not a bill.
Run /rec before you hit record. It applies to every open chat within 3 seconds, and /rec off turns it off everywhere.
.env file in the project folder or its parents, emails, names, phone numbers, street addresses, card, account, and ID numbers, money, and figures next to business words (revenue, margin, payroll, and so on). /rec strict also masks every large figure and percentage..env files, credentials, SSH and cloud keys, Claude's memory folder, and finance documents (payroll, invoices, contracts, budgets, forecasts, tax files). Finance tools like QuickBooks stay closed too. Claude is told it can't open them while recording./rec config/rec config creates ~/.claude/mods-data/recording-mode/config.json:
{
"keepNames": ["Your Name"],
"names": ["A Teammate", "A Client"],
"privatePaths": ["clients/", "finance/", "my-private-notes.md"],
"businessTools": [],
"closedTools": []
}
Save the file and run /rec again.
Limits: it changes what is drawn on screen and what Claude may open, not what is stored. Pattern masking can't catch everything written in plain words, so watch your footage before you publish. The chat titles in the Desktop app's sidebar can't be masked by a mod.
Type /goal <what done looks like> as usual. Claude first writes its plan with the goal meter's task tool, every task sized S, M, or L, then marks each task started and done as it works.
5 of 8 tasks · 69%, a progress bar, 38m elapsed · about 17m left (≈3:40pm) · plan grew 7 → 8 · 1 agent running, the last tasks done, what's running and which agent runs it, the next tasks, and the goal check's last "not met yet" reason. Press g for the full list.◎ goal 69% · ~17m, visible even when the line above the prompt is hidden./goals: this chat's whole task list, plus every other chat's goal from the last 12 hours./goals hide|show (the line above the prompt), /goals strict on (no file edits in a goal before the plan exists), /goals clear.It sends no extra requests to Claude. The task tool calls Claude makes are a few small ones per goal. A plan Claude makes without /goal shows the same way.
When you run several chats in one project, two of them can end up changing the same file without knowing it. Before Claude edits or writes a file, Collision Guard checks whether another open chat changed that file in the last 30 minutes. If one did, it asks:
/guard shows what it sees, /guard off turns it off, and /guard window 60 changes the 30-minute window. Each chat keeps a small list of the files it changed, and nothing is added to Claude's prompt.
Everything stays on your machine, under ~/.claude/mods-data/: Cache Keeper's session heartbeats and saved handoffs, the recording flag and config, each chat's goal plan, and Collision Guard's file lists. The mods make no network calls of their own. Cache Keeper's keep-warm pings are small model requests, sent only while /keepwarm is on.
MIT. See LICENSE.
hooks/register.mjs 1304 lines1// Cache Keeper: watches this session's prompt cache, keeps big caches warm on
2// request, refuses a cold send without asking first, and shows every local
3// session on one board (/board).
4//
5// Why: each request re-reads the whole context. From the cache that costs about
6// a tenth of normal input. Once the cache expires (1 hour idle on a subscription,
7// 5 minutes on the default API TTL), the next message writes the whole context
8// again at 1.25x to 2x input. A cache read also restarts the timer, so a tiny
9// ping before expiry costs a fraction of a rewrite.
10
11import { rewriteCost, requestCost, totalInput, cachedShare, priceFor } from './pricing.mjs'
12import { tokens, usd, minutes, clock, clip, basename, pad } from './fmt.mjs'
13import { makeMasker } from './privacy.mjs'
14import { classifyColdRestart, settleKeepWarm, summarize, pruneEvents } from './ledger.mjs'
15import { VERSION } from './version.mjs'
16import { endCompactDue } from './endcompact.mjs'
17import { slideUntil, pingDue } from './keepwarmwindow.mjs'
18import { shouldBlockCompact, summarizeIdleBlocks, vetoResult, blockOverridden } from './idleguard.mjs'
19import { DEFAULT_PARAMS, advise, calibrate, gapShare, stepsPerTurn, horizonFrom, adviceSummary } from './compact.mjs'
20
21const MIN = 60000
22const HEARTBEAT_EVERY = 30000
23const LEDGER_DAYS = 30
24const PANE = 'cache-board'
25
26// This session
27const S = {
28 id: '',
29 cwd: '',
30 label: '',
31 model: '',
32 lastActivity: 0, // last main-loop request or keep-warm ping that touched the cache
33 ctx: 0,
34 window: 0,
35 costUsd: 0,
36 ttlMin: 60,
37 ttlSource: 'default',
38 coldRestarts: [],
39 // The ledger (ledger.mjs): what keep warm cost and saved, what cold restarts cost
40 lastRealAt: 0, // last main-loop request; pings don't count
41 episode: null, // pings since lastRealAt: { lastRealAt, pings, usd }, settled by the next request
42 pingReadTokens: 0, // cached tokens the pings read, for the measured cost per token
43 longestSleepMs: 0, // longest stall of the 30 s timer since lastRealAt (sleep, suspend)
44 pendingResume: false,
45 turns: 0, // messages this session: the base of the gap share and requests per message
46 steps: 0, // model requests of the main loop
47 longGaps: 0, // messages that arrived after a gap longer than the cache TTL
48 advisedCtx: 0, // context size the 'worth compacting' notice was last shown for
49 adviceTurns: null, // S.turns at this session's first notice; the outcome is written when the session ends
50 adviceCtx: 0,
51 adviceCompactedAfter: null, // messages between that notice and the first compaction after it
52 events: [], // this session id's ledger events, also in $.store as ledger:<id>
53 working: false,
54 turnId: '', // the main loop's running (or last) turn
55 waitingSince: 0,
56 waitingFor: '',
57 tool: '',
58 lastTurnEnd: 0,
59 keepWarm: false,
60 keepWarmUntil: 0,
61 keepWarmSpan: 0, // hours the limit is restarted to on each real message
62 pings: 0,
63 pingUsd: 0,
64 lastPingAt: 0,
65 warnedFor: 0,
66 agentsRunning: 0,
67 rateLimits: [],
68}
69
70const DEFAULTS = { bigTokens: 150000, guard: true, ttlMin: 0, alerts: true, keepWarmHours: 12, autoCompactTokens: 0, horizonTurns: 0, idleGuard: true, endCompactTokens: 200000 } // horizonTurns 0 = learn it from notices
71const settings = { ...DEFAULTS }
72const names = { cache: 'cache', keepwarm: 'keepwarm', board: 'board', handoff: 'handoff' }
73
74let now = 0
75let home = ''
76let guardOff = false
77let lastHeartbeat = 0
78let rec = { on: false, strict: false }
79let mask = (s) => s
80let requestOut = false // a main-loop model request is in flight; a turn that only waits has none
81let fleet = [] // heartbeats of every session, this one included
82const alerted = new Map() // sessionId -> state or warning key already announced
83let paneOpen = false
84let justCompacted = false
85let selfCompacting = false // our own compaction: the idle guard must let it through
86let lastBlock = null // { at, ctx } of the last idle compaction we vetoed
87let lastTickAt = 0
88let compactCache = null // { at, params, sources }: what advise() runs on, refreshed every few minutes
89const COMPACT_REFRESH = 10 * MIN
90let autoAsked = 0 // lastActivity the auto-compact question was already asked for
91let handoffPending = false
92
93// The handoff flow. When /session-handoff runs (the band's button, /handoff, a
94// typed command, or Claude calling the skill), the answer of the turn it starts
95// is captured and saved to a file. "clear and continue" then runs /clear and
96// sends the handoff as the fresh chat's first prompt.
97const HANDOFF_SKILL = /(^|:)session-handoff$/
98const H = {
99 armed: false,
100 notTurn: '', // a turn already running when the handoff was queued: not the handoff's
101 askAfter: false, // /handoff asks "clear and continue?" once the handoff is in
102 text: '',
103 path: '',
104 continuing: false,
105}
106
107function armHandoff(insideTurn) {
108 H.armed = true
109 H.notTurn = insideTurn ? '' : S.working ? S.turnId : ''
110}
111
112// The handoff without any preamble Claude put before its heading
113function handoffBody(answer) {
114 const text = String(answer || '').trim()
115 const i = text.indexOf('# Session Handoff')
116 return i > 0 ? text.slice(i) : text
117}
118
119function fileStamp(ms) {
120 const d = new Date(ms)
121 const two = (n) => String(n).padStart(2, '0')
122 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}`
123}
124
125async function saveHandoff($, text) {
126 const project = basename(S.cwd).replace(/[^A-Za-z0-9-]+/g, '-') || 'session'
127 const path = `${home || '.'}/.claude/mods-data/cache-keeper/handoffs/${fileStamp(now)}-${project}-${S.id.slice(0, 8)}.md`.replace(/\\/g, '/')
128 await $.fs.write(path, text + '\n')
129 return path
130}
131
132async function captureHandoff($, e) {
133 H.armed = false
134 H.notTurn = ''
135 const askAfter = H.askAfter
136 H.askAfter = false
137 const body = e.reason === 'answer' ? handoffBody(e.answer) : ''
138 if (body.length < 200) {
139 $.ui.toast('The handoff turn ended without a handoff, so nothing was saved.', { timeoutMs: 8000 })
140 return
141 }
142 H.text = body
143 try {
144 H.path = await saveHandoff($, body)
145 } catch {
146 H.path = ''
147 }
148 const saved = H.path ? `Handoff saved to ${H.path}.` : 'Handoff captured (the backup file could not be written).'
149 $.ui.toast(`${saved} Press c on the band or type /${names.handoff} continue to clear and continue.`, { timeoutMs: 15000 })
150 // Off the hook: the turn is ending, and a dialog would hold it open
151 if (askAfter) $.clock.after(300, () => offerContinue($).catch(() => {}))
152}
153
154async function offerContinue($) {
155 if (!H.text) return
156 let answer = ''
157 try {
158 answer = await $.ui.ask('Handoff saved. Clear this chat and continue with it in a fresh context?', ['Clear and continue', 'Keep this chat'])
159 } catch {
160 return // dismissed: the band button and /handoff continue still work
161 }
162 if (answer === 'Clear and continue') await clearAndContinue($)
163}
164
165function continuationPrompt() {
166 const where = H.path ? ` (saved at ${H.path})` : ''
167 return `Handoff from my previous session${where}:\n\n${H.text}`
168}
169
170async function clearAndContinue($) {
171 if (H.continuing) return
172 if (!H.text) {
173 $.ui.toast(`No handoff ready. Press h on the band or type /${names.handoff} first.`, { timeoutMs: 8000 })
174 return
175 }
176 if (S.working) {
177 $.ui.toast('Claude is still working. Clear and continue once this turn ends.', { timeoutMs: 8000 })
178 return
179 }
180 const text = continuationPrompt()
181 const path = H.path
182 H.continuing = true
183 $.ui.invalidate('ui.render')
184 try {
185 await $.command.run({ command: 'clear', args: '' })
186 } catch (err) {
187 H.continuing = false
188 $.ui.invalidate('ui.render')
189 $.ui.toast('Could not run /clear: ' + clip(String((err && err.message) || err), 100) + (path ? ` The handoff is saved at ${path}.` : ''), { timeoutMs: 10000 })
190 return
191 }
192 H.text = ''
193 H.path = ''
194 try {
195 await $.prompt.submit({ text, asUser: true })
196 } catch {
197 // Not sent: leave it in the prompt box for one Enter
198 let filled = false
199 try {
200 filled = !!(await $.prompt.fill({ text })).isFilled
201 } catch {
202 filled = false
203 }
204 $.ui.toast(filled ? 'Cleared. The handoff is in the prompt box: press Enter to send it.' : `Cleared, but the handoff could not be sent.${path ? ` It's saved at ${path}.` : ''}`, { timeoutMs: 10000 })
205 } finally {
206 H.continuing = false
207 $.ui.invalidate('ui.render')
208 }
209}
210
211// Runs the /session-handoff skill as if you typed it. The engine queues it
212// until Claude finishes the current turn.
213async function runHandoff($) {
214 if (handoffPending) {
215 $.ui.toast('Session handoff is already queued.')
216 return
217 }
218 try {
219 const commands = await $.command.list()
220 const cmd = commands.find((c) => c.name === 'session-handoff') || commands.find((c) => HANDOFF_SKILL.test(c.name))
221 if (!cmd) {
222 H.askAfter = false
223 $.ui.toast('No /session-handoff skill in this session.', { timeoutMs: 8000 })
224 return
225 }
226 handoffPending = true
227 armHandoff(false)
228 $.ui.invalidate('ui.render')
229 $.ui.toast(S.working ? 'Session handoff queued: it runs when Claude finishes this turn.' : 'Running /session-handoff.')
230 await $.command.run({ command: cmd.name, args: '' })
231 } catch (err) {
232 H.armed = false
233 H.askAfter = false
234 $.ui.toast('Could not start /session-handoff: ' + clip(String((err && err.message) || err), 100), { timeoutMs: 8000 })
235 } finally {
236 handoffPending = false
237 $.ui.invalidate('ui.render')
238 }
239}
240
241function ttlMin() {
242 return settings.ttlMin || S.ttlMin
243}
244
245// Plan limit windows as the API reports them: five_hour, seven_day, spend_limit
246const LIMIT_LABEL = { five_hour: '5h', seven_day: 'week', spend_limit: 'spend' }
247
248function limitsText(limits) {
249 return limits.map((l) => `${LIMIT_LABEL[l.kind] || l.kind} ${Math.round(l.percentUsed)}%`).join(' · ')
250}
251
252function limitTone(limits, extra) {
253 const top = Math.max(...limits.map((l) => l.percentUsed || 0))
254 if (top >= 95) return { ...extra, color: 'red', bold: true }
255 if (top >= 80) return { ...extra, color: 'yellow' }
256 return { ...extra, dimColor: true }
257}
258
259function money(v) {
260 return rec.strict ? '$•••' : usd(v)
261}
262
263function msLeft(lastActivity, ttl) {
264 if (!lastActivity) return null
265 return ttl * MIN - (now - lastActivity)
266}
267
268function cacheState(hb) {
269 const left = msLeft(hb.lastActivity, hb.ttlMin)
270 if (left === null) return { kind: 'unknown', left: 0 }
271 if (hb.keepWarm) return { kind: 'kept', left }
272 if (left <= 0) return { kind: 'cold', left }
273 if (left <= 5 * MIN) return { kind: 'cooling', left }
274 return { kind: 'warm', left }
275}
276
277function myHeartbeat() {
278 const coldUsd = S.coldRestarts.reduce((a, c) => a + c.usd, 0)
279 let state = 'idle'
280 if (S.waitingSince && now - S.waitingSince > 5000) state = 'waiting'
281 else if (S.working) state = 'working'
282 return {
283 id: S.id,
284 label: S.label,
285 cwd: S.cwd,
286 model: S.model,
287 state,
288 waitingFor: S.waitingFor,
289 tool: S.tool,
290 ctx: S.ctx,
291 window: S.window,
292 ttlMin: ttlMin(),
293 lastActivity: S.lastActivity,
294 keepWarm: S.keepWarm,
295 keepWarmUntil: S.keepWarmUntil,
296 pings: S.pings,
297 pingUsd: S.pingUsd,
298 costUsd: S.costUsd,
299 coldCount: S.coldRestarts.length,
300 coldUsd,
301 agentsRunning: S.agentsRunning,
302 lastTurnEnd: S.lastTurnEnd,
303 updatedAt: now,
304 }
305}
306
307async function registerCommand($, name, description, argumentHint, immediate) {
308 const spec = immediate ? { name, description, argumentHint, immediate: true } : { name, description, argumentHint }
309 try {
310 await $.command.register(spec)
311 return name
312 } catch {
313 try {
314 await $.command.register({ ...spec, name: 'ck-' + name })
315 return 'ck-' + name
316 } catch {
317 return null
318 }
319 }
320}
321
322async function readRecording($) {
323 try {
324 const path = (home || '.') + '/.claude/mods-data/recording.json'
325 if (!(await $.fs.exists(path))) {
326 rec = { on: false, strict: false }
327 } else {
328 const flag = JSON.parse(await $.fs.read(path))
329 rec = { on: !!flag.on, strict: !!flag.on && !!flag.strict }
330 }
331 } catch {
332 rec = { on: false, strict: false }
333 }
334 mask = rec.on ? makeMasker({ strict: rec.strict }) : (s) => s
335}
336
337async function heartbeat($, force) {
338 if (!S.id) return
339 if (!force && now - lastHeartbeat < 5000) return
340 lastHeartbeat = now
341 try {
342 const agents = await $.agent.list()
343 S.agentsRunning = agents.filter((a) => a.status === 'running').length
344 } catch {
345 // agent list unavailable: keep the last count
346 }
347 await $.store.set('hb:' + S.id, myHeartbeat())
348}
349
350async function readFleet($) {
351 const keys = (await $.store.keys()).filter((k) => k.startsWith('hb:'))
352 const list = []
353 for (const k of keys) {
354 const hb = await $.store.get(k)
355 if (!hb || typeof hb !== 'object') continue
356 if (hb.id === S.id) continue
357 // a session that stopped heartbeating an hour ago has ended or crashed
358 if (now - (hb.updatedAt || 0) > 60 * MIN) {
359 await $.store.delete(k)
360 continue
361 }
362 list.push({ ...hb, stale: now - (hb.updatedAt || 0) > 3 * MIN })
363 }
364 list.push({ ...myHeartbeat(), self: true })
365 fleet = list
366}
367
368// Commands another session's board sent this one, through the shared store
369async function applyRemoteCommands($) {
370 const c = await $.store.get('cmd:' + S.id)
371 if (!c) return
372 await $.store.delete('cmd:' + S.id)
373 if (c.keepWarm === true) startKeepWarm($, settings.keepWarmHours)
374 if (c.keepWarm === false) stopKeepWarm($, 'stopped from the board')
375}
376
377function startKeepWarm($, hours) {
378 S.keepWarm = true
379 S.keepWarmSpan = hours
380 S.keepWarmUntil = now + hours * 60 * MIN
381 $.ui.toast(`Keeping this cache warm until ${clock(S.keepWarmUntil)}. A ping reads the cache before it expires.`)
382}
383
384function stopKeepWarm($, why) {
385 if (!S.keepWarm) return
386 S.keepWarm = false
387 $.ui.toast(`Keep warm off (${why}). ${S.pings} ping(s), ${money(S.pingUsd)}.`, { timeoutMs: 8000 })
388}
389
390// The last ping would be followed by an expired cache and a full-price rewrite of
391// a big context: compact now, while the cache is warm, and stop pinging.
392async function compactInsteadOfLastPing($) {
393 const due = endCompactDue({
394 keepWarmOn: S.keepWarm,
395 working: S.working,
396 ttlMin: ttlMin(),
397 ctx: S.ctx,
398 threshold: settings.endCompactTokens,
399 lastActivity: S.lastActivity,
400 now,
401 keepWarmUntil: S.keepWarmUntil,
402 silentMs: S.lastRealAt ? now - S.lastRealAt : 0,
403 })
404 if (!due) return false
405 const ctx = S.ctx
406 selfCompacting = true
407 try {
408 await $.session.compact()
409 } catch (err) {
410 await recordEvent($, { at: now, kind: 'end-compact', v: VERSION, outcome: 'failed', ctx, why: String((err && err.message) || err).slice(0, 120) })
411 $.ui.toast('Keep warm could not compact before its time limit; pinging instead.', { timeoutMs: 8000 })
412 return false
413 } finally {
414 selfCompacting = false
415 }
416 await recordEvent($, { at: now, kind: 'end-compact', v: VERSION, outcome: 'compacted', ctx })
417 stopKeepWarm($, `compacted ${tokens(ctx)} tokens before the time limit`)
418 return true
419}
420
421async function keepWarmStep($) {
422 if (!S.keepWarm) return
423 if (now >= S.keepWarmUntil) {
424 if (await compactInsteadOfLastPing($)) return
425 return stopKeepWarm($, 'time limit reached')
426 }
427 const ttl = ttlMin()
428 if (!pingDue({ requestOut, lastActivity: S.lastActivity, now, ttlMin: ttl })) return
429 if (await compactInsteadOfLastPing($)) return
430 let reply = null
431 try {
432 reply = await $.model.fork({ prompt: 'cache-keeper keep-alive ping. Reply with only: ok' })
433 } catch {
434 reply = null
435 }
436 // Mid-turn the fork may have nothing to share yet: try again on the next tick
437 if (S.working && (!reply || reply.reason === 'nothing-to-fork' || reply.reason === 'aborted')) return
438 if (!reply || !reply.usage) {
439 return stopKeepWarm($, 'the ping got no answer, so the cache may already be cold')
440 }
441 const u = reply.usage
442 S.pings += 1
443 S.lastPingAt = now
444 const cost = requestCost(u, S.model, ttl)
445 S.pingUsd += cost
446 S.pingReadTokens += u.cache_read_input_tokens || 0
447 if (!S.episode) S.episode = { lastRealAt: S.lastRealAt, pings: 0, usd: 0 }
448 S.episode.pings += 1
449 S.episode.usd += cost
450 // A ping that writes instead of reading did not hit the cache: stop paying for it
451 if ((u.cache_creation_input_tokens || 0) > 0.1 * Math.max(1, u.cache_read_input_tokens || 0)) {
452 return stopKeepWarm($, `the ping wrote ${tokens(u.cache_creation_input_tokens)} tokens instead of reading the cache`)
453 }
454 S.lastActivity = now
455}
456
457function warnStep($) {
458 if (!settings.alerts) return
459 for (const hb of fleet) {
460 if (hb.stale || hb.keepWarm || (hb.ctx || 0) < settings.bigTokens) continue
461 const st = cacheState(hb)
462 if (st.kind !== 'cooling') continue
463 const key = 'cool:' + hb.lastActivity
464 if (alerted.get(hb.id) === key) continue
465 alerted.set(hb.id, key)
466 const cost = money(rewriteCost(hb.ctx, hb.model, hb.ttlMin))
467 const who = hb.self ? 'This session' : `"${mask(hb.label)}"`
468 const how = hb.self ? `type /${names.keepwarm} or press 1` : `open /${names.board} to keep it warm`
469 $.ui.toast(`${who}: ${tokens(hb.ctx)}-token cache goes cold in ${minutes(st.left)}. Rewriting it costs about ${cost}. ${how}.`, { timeoutMs: 15000 })
470 }
471 // Another session started waiting on a permission or a question
472 for (const hb of fleet) {
473 if (hb.self || hb.stale) continue
474 const prev = alerted.get('state:' + hb.id)
475 alerted.set('state:' + hb.id, hb.state)
476 if (hb.state === 'waiting' && prev && prev !== 'waiting') {
477 $.ui.toast(`"${mask(hb.label)}" is waiting on you${hb.waitingFor ? ': ' + hb.waitingFor : ''}`, { timeoutMs: 10000 })
478 }
479 }
480}
481
482// Auto-compact: while the cache is still warm a compaction reads the big context
483// at the cheap cache-read price; after it expires the same compaction pays a full
484// rewrite first. So near the end of the window, on a context over the threshold,
485// ask once whether to compact now. Off by default (/cache autocompact 300k).
486async function autoCompactStep($) {
487 if (!settings.autoCompactTokens || S.keepWarm || S.working || !S.lastActivity) return
488 if (S.ctx < settings.autoCompactTokens || autoAsked === S.lastActivity) return
489 const ttl = ttlMin()
490 const left = msLeft(S.lastActivity, ttl)
491 const lead = ttl >= 60 ? 10 * MIN : 2 * MIN
492 if (left === null || left <= 0 || left > lead) return
493 autoAsked = S.lastActivity
494 let answer = 'Not now'
495 try {
496 answer = await $.ui.ask(
497 `Cache goes cold in ${minutes(left)}. Compacting ${tokens(S.ctx)} tokens now reads them at the cache price; after that it costs about ${money(rewriteCost(S.ctx, S.model, ttl))} to rewrite first. Compact now?`,
498 ['Compact now', 'Not now'],
499 )
500 } catch {
501 return
502 }
503 if (answer !== 'Compact now') return
504 try {
505 await $.session.compact()
506 } catch {
507 $.ui.toast('Auto-compact did not run (a turn was in progress or compaction was refused).', { timeoutMs: 8000 })
508 }
509}
510
511// Ledger events go to this session's own store key, so sessions never overwrite
512// each other; anything older than LEDGER_DAYS is dropped on the way.
513async function recordEvent($, ev) {
514 S.events = pruneEvents([...S.events, ev], now - LEDGER_DAYS * 24 * 60 * MIN)
515 try {
516 if (S.id) await $.store.set('ledger:' + S.id, S.events)
517 } catch {
518 // the store refused: keep the event in memory, never fail the request it rides on
519 }
520}
521
522// Pings nobody came back for (the chat closed or cleared first) were spent for nothing
523async function abandonEpisode($) {
524 if (!S.episode) return
525 const ep = S.episode
526 S.episode = null
527 await recordEvent($, { at: now, kind: 'keepwarm', outcome: 'abandoned', pings: ep.pings, pingUsd: ep.usd, savedUsd: 0 })
528}
529
530async function readLedger($) {
531 const cutoff = now - LEDGER_DAYS * 24 * 60 * MIN
532 const all = []
533 for (const k of (await $.store.keys()).filter((key) => key.startsWith('ledger:'))) {
534 const list = k === 'ledger:' + S.id ? S.events : await $.store.get(k)
535 const kept = pruneEvents(Array.isArray(list) ? list : [], cutoff)
536 if (!kept.length && k !== 'ledger:' + S.id) await $.store.delete(k)
537 all.push(...kept)
538 }
539 return all
540}
541
542const COLD_LABELS = {
543 avoidable: 'avoidable with keep warm',
544 'not-worth': 'keep warm would have cost more',
545 asleep: 'computer asleep or Claude Code suspended',
546 'too-long': 'idle over 24 h',
547 'keepwarm-failed': 'keep warm was on and missed',
548}
549
550function ledgerLines(title, s) {
551 const k = s.keepWarm
552 const c = s.cold
553 const lines = [`${title}:`]
554 lines.push(` Keep warm: ${k.pings} ping(s), spent ${money(k.pingUsd)}; saved ${money(k.savedUsd)} on ${k.saved} return(s) that would have been cold; net ${money(k.netUsd)}.`)
555 if (k.unneeded + k.failed + k.abandoned) lines.push(` Pings spent for nothing ${money(k.wastedUsd)}: ${k.unneeded} unneeded (cache was still warm), ${k.failed} missed, ${k.abandoned} never came back.`)
556 lines.push(` Cold restarts: ${c.count}, spent ${money(c.usd)}; keep warm could have saved ${money(c.avoidableUsd)} of it.`)
557 for (const [cat, v] of Object.entries(c.byCategory)) {
558 lines.push(` ${COLD_LABELS[cat] || cat}: ${v.count}, ${money(v.usd)}${v.avoidableUsd ? `, saving ${money(v.avoidableUsd)}` : ''}`)
559 }
560 if (s.resume.count) lines.push(` Resumed chats: ${s.resume.count}, spent ${money(s.resume.usd)} rewriting the cache (the chat was closed, nothing could keep it warm).`)
561 return lines
562}
563
564async function statsText($) {
565 const all = await readLedger($)
566 const day = 24 * 60 * MIN
567 const lines = [
568 `Cache Keeper ${VERSION} (the version this chat loaded; chats started before an update keep the old one).`,
569 ...ledgerLines('This chat', summarize(S.events)),
570 ...ledgerLines('Last 7 days, all chats', summarize(all, now - 7 * day)),
571 ...ledgerLines('Last 30 days, all chats', summarize(all, now - 30 * day)),
572 ]
573 if (S.episode) lines.push(`Open keep-warm stretch: ${S.episode.pings} ping(s), ${money(S.episode.usd)}, settled by your next message.`)
574 const adv = adviceSummary(all, now - 30 * day)
575 if (adv.shown) lines.push(`Compaction notices, last 30 days: ${adv.shown} shown, you compacted after ${adv.acted} (${Math.round(adv.actedShare * 100)}%); about ${Math.round(adv.medianRemaining)} message(s) were left after a notice.`)
576 const ig = summarizeIdleBlocks(all, now - 30 * day)
577 if (ig.blocked || ig.passed || ig.overridden || ig.legacy) lines.push(`Idle-compaction guard, last 30 days: blocked ${ig.blocked} (compacted anyway: ${ig.overridden})${ig.lastBlockedAt ? ` (last ${minutes(now - ig.lastBlockedAt)} ago)` : ''}; automatic compactions let through with keep warm on: ${ig.passed}.${ig.legacy ? ` Not counted: ${ig.legacy} older record(s) from versions before 1.3.2, which did not block anything.` : ''}`)
578 else lines.push('Idle-compaction guard, last 30 days: nothing to block yet (no automatic compaction while keep warm was on).')
579 const ec = all.filter((ev) => ev.kind === 'end-compact' && ev.at >= now - 30 * day)
580 if (ec.length) lines.push(`Compacted before keep warm ended, last 30 days: ${ec.filter((ev) => ev.outcome === 'compacted').length} of ${ec.length} attempt(s), contexts ${ec.map((ev) => tokens(ev.ctx)).join(', ')}.`)
581 lines.push('Prices are API list prices; on a subscription they are usage against your plan limits. Contexts under 30k tokens are not counted.')
582 return lines.join('\n')
583}
584
585// Per-session counters for the compaction advice: messages, requests, long gaps
586async function saveUsage($) {
587 try {
588 if (S.id) await $.store.set('usage:' + S.id, { at: now, turns: S.turns, steps: S.steps, longGaps: S.longGaps })
589 } catch {
590 // the store refused: the counters stay in memory
591 }
592}
593
594async function readUsageTotals($) {
595 const cutoff = now - LEDGER_DAYS * 24 * 60 * MIN
596 const total = { turns: S.turns, steps: S.steps, longGaps: S.longGaps }
597 for (const k of (await $.store.keys()).filter((key) => key.startsWith('usage:'))) {
598 if (k === 'usage:' + S.id) continue
599 const u = await $.store.get(k)
600 if (!u || typeof u !== 'object' || (u.at || 0) < cutoff) {
601 await $.store.delete(k)
602 continue
603 }
604 total.turns += u.turns || 0
605 total.steps += u.steps || 0
606 total.longGaps += u.longGaps || 0
607 }
608 return total
609}
610
611// A notice's outcome, written when its session ends or is cleared: how many
612// messages were still to come, and whether (and how soon) the owner compacted.
613async function flushAdvice($) {
614 if (S.adviceTurns === null) return
615 const compacted = S.adviceCompactedAfter !== null
616 const ev = { at: now, kind: 'advice', ctx: S.adviceCtx, remaining: Math.max(0, S.turns - S.adviceTurns), compacted, compactedAfter: S.adviceCompactedAfter }
617 S.adviceTurns = null
618 S.adviceCompactedAfter = null
619 await recordEvent($, ev)
620}
621
622// What advise() runs on: defaults, replaced by what this machine measured
623async function refreshCompactParams($) {
624 const usage = await readUsageTotals($)
625 const ledger = await readLedger($)
626 const cal = calibrate(ledger, DEFAULT_PARAMS)
627 const horizon = horizonFrom(ledger, DEFAULT_PARAMS.horizonTurns)
628 const gap = gapShare(usage, DEFAULT_PARAMS.gapShare)
629 compactCache = {
630 at: now,
631 usage,
632 ledger,
633 params: { ...cal.params, gapShare: gap.value, stepsPerTurn: stepsPerTurn(usage, DEFAULT_PARAMS.stepsPerTurn), horizonTurns: settings.horizonTurns > 0 ? settings.horizonTurns : horizon.value },
634 sources: { ...cal.sources, gapShare: gap.source, stepsPerTurn: usage.turns >= 30 ? 'measured' : 'default', horizonTurns: settings.horizonTurns > 0 ? 'set' : horizon.source },
635 }
636}
637
638function compactAdvice(warm, sendPending = false) {
639 if (!compactCache || !S.model) return null
640 return advise({ ctx: S.ctx, model: S.model, ttlMin: ttlMin(), params: compactCache.params, warm, sendPending, keepWarmOn: S.keepWarm })
641}
642
643// One notice when compacting starts to pay, and again only after the context doubled. Not in the bar: the figures are on demand in /cache compact.
644function compactNoticeStep($) {
645 if (!settings.alerts || S.working || !S.lastActivity) return
646 const st = cacheState(myHeartbeat())
647 if (st.kind !== 'warm' && st.kind !== 'cooling' && st.kind !== 'kept') return
648 if (S.advisedCtx && S.ctx < S.advisedCtx * 2) return
649 const a = compactAdvice(true)
650 if (!a || a.verdict !== 'compact') return
651 S.advisedCtx = S.ctx
652 if (S.adviceTurns === null) {
653 S.adviceTurns = S.turns
654 S.adviceCtx = S.ctx
655 S.adviceCompactedAfter = null
656 }
657 $.ui.toast(`Worth compacting now: ${tokens(S.ctx)} tokens. It costs about ${money(a.costUsd)} and pays back in about ${Math.max(1, Math.round(a.breakEvenTurns))} message(s). Details: /${names.cache} compact.`, { timeoutMs: 15000 })
658}
659
660const SOURCE = { default: 'assumed', measured: 'measured' }
661const HORIZON_SOURCE = { default: 'assumed', measured: 'median of the messages that were left after your earlier notices', set: 'set by you' }
662
663function compactText() {
664 if (!compactCache) return 'Not enough data yet: try again in a moment.'
665 if (!S.ctx || !S.model) return 'No request yet in this chat, so there is nothing to compact.'
666 const { params, sources, usage } = compactCache
667 const ttl = ttlMin()
668 const st = cacheState(myHeartbeat())
669 const warm = st.kind === 'warm' || st.kind === 'cooling' || st.kind === 'kept'
670 const a = compactAdvice(warm, !warm)
671 const pct = (v) => Math.round(v * 100) + '%'
672 const lines = []
673 const verdict = {
674 compact: `Worth compacting now.`,
675 wait: `Not yet: it would not pay back within ${params.horizonTurns} messages.`,
676 'too-small': `Not worth it: the chat is barely bigger than what a compaction leaves (${tokens(params.newCtxTokens)}).`,
677 }[a.verdict]
678 lines.push(`Compacting this chat (${tokens(S.ctx)} tokens): ${verdict}`)
679 lines.push(` Cost ${money(a.costUsd)} ${warm ? '(cache warm: it reads the chat at the cache price)' : '(cache cold and your next message rewrites it anyway: only the summary and the new prefix are extra)'}.`)
680 lines.push(` Saves about ${money(a.savingPerTurnUsd)} per message; pays back after about ${Number.isFinite(a.breakEvenTurns) ? Math.max(1, Math.round(a.breakEvenTurns)) : 'never'} message(s). Horizon: ${params.horizonTurns} message(s) still to come (${HORIZON_SOURCE[sources.horizonTurns]}; /${names.cache} horizon N|auto).`)
681 if (Number.isFinite(a.breakEvenCtx)) lines.push(` At ${params.horizonTurns} messages it pays from about ${tokens(Math.round(a.breakEvenCtx))} tokens up.`)
682 if (warm && Number.isFinite(a.idleReturnThreshold) && a.idleReturnThreshold <= 1) {
683 lines.push(` If you step away, compacting before the cache expires beats letting it expire when you come back with probability above ${pct(a.idleReturnThreshold)}.`)
684 }
685 lines.push(`Behind the figures (${SOURCE[sources.newCtxTokens]} = from defaults, measured = from your own compactions here):`)
686 lines.push(` After a compaction the next request carries ${tokens(Math.round(params.newCtxTokens))} tokens (${SOURCE[sources.newCtxTokens]}), ${pct(params.rewriteShare)} of it written (${SOURCE[sources.rewriteShare]}), summary ${tokens(Math.round(params.summaryTokens))} tokens (${SOURCE[sources.summaryTokens]}).`)
687 lines.push(` ${params.stepsPerTurn.toFixed(1)} model requests per message (${SOURCE[sources.stepsPerTurn]}), ${pct(params.gapShare)} of messages after a gap over ${ttl} min (${SOURCE[sources.gapShare]}${usage.turns ? `, ${usage.turns} messages seen` : ''}).`)
688 const adv = adviceSummary(compactCache.ledger || [])
689 if (adv.shown) lines.push(`Earlier notices: ${adv.shown}, you compacted after ${adv.acted} of them${adv.medianMessagesToAct !== null ? ` (usually ${Math.round(adv.medianMessagesToAct)} message(s) later)` : ''}; about ${Math.round(adv.medianRemaining)} message(s) were left after a notice.`)
690 lines.push('Money only: a summary loses detail, and this does not price that.')
691 return lines.join('\n')
692}
693
694async function tick($) {
695 now = await $.clock.now()
696 if (!compactCache || now - compactCache.at > COMPACT_REFRESH) await refreshCompactParams($).catch(() => {})
697 // The 30 s timer stalls while the machine sleeps or the process is suspended
698 if (lastTickAt && now - lastTickAt > 3 * HEARTBEAT_EVERY) {
699 S.longestSleepMs = Math.max(S.longestSleepMs, now - lastTickAt - HEARTBEAT_EVERY)
700 }
701 lastTickAt = now
702 await readRecording($)
703 await applyRemoteCommands($)
704 await keepWarmStep($)
705 autoCompactStep($).catch(() => {})
706 await heartbeat($, true)
707 await readFleet($)
708 warnStep($)
709 compactNoticeStep($)
710 $.ui.invalidate('ui.render')
711}
712
713async function startFromEnvironment($) {
714 guardOff = (await $.env.get('CACHE_KEEPER_GUARD')) === 'off'
715 const value = await $.env.get('CACHE_KEEPER_KEEPWARM_HOURS')
716 const hours = typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value) ? Number(value) : 0
717 if (hours >= 1 && hours <= 72) startKeepWarm($, hours)
718}
719
720async function loadSettings($) {
721 const saved = await $.store.get('settings')
722 if (saved && typeof saved === 'object') Object.assign(settings, saved)
723}
724
725function statusText() {
726 const st = cacheState(myHeartbeat())
727 const ttl = ttlMin()
728 const lines = []
729 lines.push(`Cache Keeper: ${tokens(S.ctx)} tokens in context on ${priceFor(S.model).id}, cache window ${ttl} min (${settings.ttlMin ? 'set by you' : S.ttlSource}).`)
730 if (st.kind === 'unknown') lines.push('No request yet this session, so the cache state is unknown.')
731 if (st.kind === 'warm' || st.kind === 'cooling') lines.push(`Cache is warm for about ${minutes(st.left)} more.`)
732 if (st.kind === 'cold') lines.push(`Cache went cold ${minutes(-st.left)} ago. The next message rewrites it for about ${money(rewriteCost(S.ctx, S.model, ttl))}.`)
733 if (S.keepWarm) lines.push(`Keep warm is on until ${clock(S.keepWarmUntil)}: ${S.pings} ping(s), ${money(S.pingUsd)} so far.`)
734 if (S.rateLimits.length) lines.push(`Plan limits used: ${limitsText(S.rateLimits)}.`)
735 lines.push(`Session cost so far: ${money(S.costUsd)}. Cold restarts this session: ${S.coldRestarts.length} (${money(S.coldRestarts.reduce((a, c) => a + c.usd, 0))}).`)
736 const led = summarize(S.events)
737 lines.push(`Ledger, this chat: keep warm net ${money(led.keepWarm.netUsd)} (spent ${money(led.keepWarm.pingUsd)}, saved ${money(led.keepWarm.savedUsd)}); cold restarts ${money(led.cold.usd)}, of which avoidable ${money(led.cold.avoidableUsd)}. Details: /${names.cache} stats.`)
738 lines.push(`Cold-send guard: ${settings.guard && !guardOff ? 'on' : 'off'} for contexts over ${tokens(settings.bigTokens)} tokens. Alerts: ${settings.alerts ? 'on' : 'off'}.`)
739 lines.push(`Idle-compaction guard: ${settings.idleGuard ? 'on (blocks Claude Code\'s own idle compaction while keep warm is on)' : 'off'}. /cache idleguard on|off.`)
740 lines.push(`Compact before keep warm ends: ${settings.endCompactTokens ? `on for contexts over ${tokens(settings.endCompactTokens)} tokens, when nobody wrote for 30 min and the last ping is due` : 'off'}. /cache endcompact 200k|off.`)
741 lines.push(`Auto-compact: ${settings.autoCompactTokens ? `asks near the end of the cache window on contexts over ${tokens(settings.autoCompactTokens)} tokens` : 'off'}.`)
742 lines.push(`Settings: /${names.cache} ttl 5|60|auto, /${names.cache} guard on|off, /${names.cache} big 150k, /${names.cache} alerts on|off, /${names.cache} autocompact 300k|off, /${names.cache} reset (back to defaults), /${names.cache} stats (what keep warm and cold restarts cost), /${names.cache} compact (is compacting worth it now), /${names.cache} horizon 15 (messages you expect to send). Board: /${names.board}.`)
743 return lines.join('\n')
744}
745
746function parseTokens(text) {
747 const m = String(text || '').trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([km]?)$/)
748 if (!m) return null
749 return Math.round(Number(m[1]) * (m[2] === 'm' ? 1e6 : m[2] === 'k' ? 1e3 : 1))
750}
751
752export function register(on) {
753 on('session.start', async ($, e, next) => {
754 now = await $.clock.now()
755 home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
756 S.id = await $.session.id()
757 S.cwd = await $.session.cwd()
758 S.label = basename(S.cwd)
759 S.model = await $.session.model()
760 await loadSettings($)
761 await startFromEnvironment($)
762 await readRecording($)
763 const saved = await $.store.get('ledger:' + S.id)
764 S.events = Array.isArray(saved) ? saved : []
765 names.cache = (await registerCommand($, 'cache', 'Cache Keeper status and settings', '[ttl 5|60|auto] [guard on|off] [big 150k] [alerts on|off] [autocompact 300k|off] [reset] [stats] [compact] [horizon 15]')) || names.cache
766 names.keepwarm = (await registerCommand($, 'keepwarm', 'Keep the prompt cache warm while you are away. Stops after N hours (default 12, max 24) with no message from you; off to stop now', '[hours|off]', true)) || names.keepwarm
767 names.board = (await registerCommand($, 'board', 'Every local Claude Code session: state, context, cache, cost', '', true)) || names.board
768 names.handoff = (await registerCommand($, 'handoff', 'Session handoff, then clear this chat and continue with it (/handoff continue)', '[continue]')) || names.handoff
769 $.clock.every(HEARTBEAT_EVERY, () => tick($).catch(() => {}))
770 await heartbeat($, true)
771 return next(e)
772 })
773
774 // /clear, /resume and /branch start from an unknown cache. The process goes
775 // on under a new session id, and no session.start fires for it.
776 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
777 now = await $.clock.now()
778 await abandonEpisode($)
779 await flushAdvice($)
780 S.lastActivity = 0
781 S.lastRealAt = 0
782 S.longestSleepMs = 0
783 S.pendingResume = e.source === 'resume'
784 S.keepWarm = false
785 await startFromEnvironment($)
786 H.armed = false
787 try {
788 S.id = (await $.session.id()) || S.id
789 } catch {
790 // keep the old id
791 }
792 S.label = basename(S.cwd)
793 const saved = await $.store.get('ledger:' + S.id)
794 S.events = Array.isArray(saved) ? saved : []
795 return next(e)
796 })
797
798 on('session.end', async ($, e, next) => {
799 now = await $.clock.now()
800 await abandonEpisode($)
801 await flushAdvice($)
802 if (S.id) await $.store.delete('hb:' + S.id)
803 return next(e)
804 })
805
806 // The first prompt names the session on the board
807 on('prompt.submit', async ($, e, next) => {
808 now = await $.clock.now()
809 if (S.label === basename(S.cwd) && e.origin && e.origin.kind === 'composer' && e.text) {
810 S.label = basename(S.cwd) + ' · ' + clip(e.text, 40)
811 }
812 // A new message in this chat makes a waiting handoff out of date (its file stays)
813 if (H.text && !H.continuing && e.origin && e.origin.kind === 'composer' && !String(e.text || '').trim().startsWith('/')) {
814 H.text = ''
815 H.path = ''
816 $.ui.invalidate('ui.render')
817 }
818 const ttl = ttlMin()
819 const left = msLeft(S.lastActivity, ttl)
820 const isCold = left !== null && left <= 0
821 const fromUser = e.origin && (e.origin.kind === 'composer' || e.origin.kind === 'bridge')
822 if (guardOff || !settings.guard || !fromUser || !isCold || S.ctx < settings.bigTokens || e.turnId) return next(e)
823 const cost = money(rewriteCost(S.ctx, S.model, ttl))
824 let answer = 'Send anyway'
825 try {
826 answer = await $.ui.ask(
827 `Cache went cold ${minutes(-left)} ago. Sending now rewrites ${tokens(S.ctx)} tokens of context (about ${cost}). What should happen?`,
828 ['Send anyway', 'Compact first, then send', 'Cancel'],
829 )
830 } catch {
831 // nobody to ask (a -p run, or the dialog was dismissed): send as typed
832 return next(e)
833 }
834 if (answer === 'Compact first, then send') {
835 try {
836 await $.session.compact()
837 } catch {
838 // compaction refused or failed: send anyway
839 }
840 return next(e)
841 }
842 if (answer === 'Send anyway') return next(e)
843 $.ui.toast('Not sent. A fresh session with a short handoff avoids the rewrite entirely.', { timeoutMs: 8000 })
844 return { drop: 'Cancelled by cache-keeper before a cold cache rewrite' }
845 })
846
847 on('turn.start', async ($, e, next) => {
848 now = await $.clock.now()
849 S.working = true
850 if (!e.agentId) {
851 S.turns += 1
852 S.keepWarmUntil = slideUntil({ keepWarmOn: S.keepWarm, spanHours: S.keepWarmSpan, now, until: S.keepWarmUntil })
853 if (S.lastRealAt && now - S.lastRealAt > ttlMin() * MIN) S.longGaps += 1
854 }
855 if (e.turnId) S.turnId = e.turnId
856 await heartbeat($, true)
857 return next(e)
858 })
859
860 // Each main-loop request: did it read the cache, or rewrite it?
861 on('turn.step', async function* ($, e, next) {
862 const startedAt = await $.clock.now()
863 if (!e.agentId) requestOut = true
864 let result
865 try {
866 result = yield* next(e)
867 } finally {
868 if (!e.agentId) requestOut = false
869 }
870 if (e.agentId || !result || !result.usage) return result
871 now = await $.clock.now()
872 S.steps += 1
873 const u = result.usage
874 const total = totalInput(u)
875 const written = u.cache_creation_input_tokens || 0
876 const gap = S.lastActivity ? startedAt - S.lastActivity : 0
877 S.model = u.model || S.model
878 const afterCompact = justCompacted
879 justCompacted = false
880 const realGap = S.lastRealAt ? startedAt - S.lastRealAt : 0
881 // Settle the keep-warm stretch this request ends
882 let keepWarmMissed = false
883 if (S.episode && afterCompact) {
884 await abandonEpisode($)
885 } else if (S.episode) {
886 const settled = settleKeepWarm({ episode: S.episode, startedAt, ttlMin: ttlMin(), usage: u, model: S.model })
887 S.episode = null
888 keepWarmMissed = settled.outcome === 'failed'
889 await recordEvent($, { at: now, kind: 'keepwarm', ...settled })
890 }
891 // The first request of a resumed chat rewrites a cache nobody could keep warm
892 if (S.pendingResume && !afterCompact && total > 30000 && written / total > 0.5) {
893 await recordEvent($, { at: now, kind: 'resume', tokens: written, usd: rewriteCost(written, S.model, ttlMin()) })
894 }
895 S.pendingResume = false
896 if (afterCompact) {
897 // The first request after a compaction writes the new, shorter context: expected.
898 // Its size and write share are what the next compaction's cost is calibrated on.
899 if (total > 0) await recordEvent($, { at: now, kind: 'compact-rebuild', written, total })
900 S.advisedCtx = 0
901 } else if (S.lastActivity && total > 30000 && written / total > 0.5) {
902 // A rewrite after 5-60 idle minutes means this session runs on the 5-minute TTL
903 if (!settings.ttlMin && gap > 5.5 * MIN && gap < S.ttlMin * MIN) {
904 S.ttlMin = 5
905 S.ttlSource = 'measured'
906 }
907 S.coldRestarts.push({ at: now, tokens: written, usd: rewriteCost(written, S.model, ttlMin()), gapMin: Math.round(gap / MIN) })
908 const measured = S.pingReadTokens ? { usd: S.pingUsd, readTokens: S.pingReadTokens } : undefined
909 const c = classifyColdRestart({
910 gapMs: realGap || gap,
911 ttlMin: ttlMin(),
912 tokens: written,
913 model: S.model,
914 keepWarmOn: S.keepWarm || keepWarmMissed,
915 longestSleepMs: S.longestSleepMs,
916 measured,
917 })
918 await recordEvent($, { at: now, kind: 'cold', category: c.category, tokens: written, usd: c.usd, avoidableUsd: c.avoidableUsd, pingsNeeded: c.pingsNeeded, gapMin: Math.round((realGap || gap) / MIN) })
919 } else if (S.lastActivity && total > 30000 && gap > 5.5 * MIN && cachedShare(u) > 0.8) {
920 // A hit after more than 5 idle minutes proves the 1-hour TTL
921 if (!settings.ttlMin) {
922 S.ttlMin = 60
923 S.ttlSource = 'measured'
924 }
925 }
926 S.ctx = total
927 S.lastActivity = now
928 S.lastRealAt = now
929 S.longestSleepMs = 0
930 return result
931 })
932
933 on('turn.complete', async ($, e, next) => {
934 const r = await next(e)
935 if (e.agentId) return r
936 now = await $.clock.now()
937 S.working = false
938 S.waitingSince = 0
939 S.tool = ''
940 S.lastTurnEnd = now
941 await saveUsage($)
942 try {
943 const usage = await $.session.usage()
944 if (usage.context && usage.context.tokens) S.ctx = usage.context.tokens
945 if (usage.context && usage.context.window) S.window = usage.context.window
946 if (usage.cost) S.costUsd = usage.cost.usd
947 if (Array.isArray(usage.rateLimits) && usage.rateLimits.length) S.rateLimits = usage.rateLimits
948 } catch {
949 // usage unavailable: keep the per-request figures
950 }
951 if (H.armed && e.turnId !== H.notTurn) await captureHandoff($, e)
952 await heartbeat($, true)
953 $.ui.invalidate('ui.render')
954 return r
955 })
956
957 on('session.compact', async ($, e, next) => {
958 now = await $.clock.now()
959 if (e.trigger !== 'precompute' && !e.agentId && !selfCompacting) {
960 const silentMs = S.lastRealAt ? now - S.lastRealAt : 0
961 const d = shouldBlockCompact({ trigger: e.trigger, keepWarmOn: S.keepWarm, working: S.working, silentMs, enabled: settings.idleGuard })
962 if (d.block || (e.trigger === 'auto' && S.keepWarm)) {
963 const silentMin = Math.round(silentMs / MIN)
964 await recordEvent($, { at: now, kind: 'idle-compact', v: VERSION, outcome: d.block ? 'blocked' : 'passed', trigger: e.trigger, silentMin, ctx: S.ctx })
965 if (d.block) {
966 $.ui.toast(`Blocked Claude Code's idle compaction: keep warm is on and nobody wrote for ${silentMin} min. /cache stats shows the log.`, { timeoutMs: 10000 })
967 lastBlock = { at: now, ctx: S.ctx }
968 return vetoResult(d)
969 }
970 }
971 }
972 const before = cacheState(myHeartbeat())
973 const r = await next(e)
974 if (e.trigger === 'precompute' || e.agentId || !r || r.skip) return r
975 justCompacted = true
976 if (S.adviceTurns !== null && S.adviceCompactedAfter === null) S.adviceCompactedAfter = Math.max(0, S.turns - S.adviceTurns)
977 const u = r.usage
978 await recordEvent($, {
979 at: now,
980 kind: 'compact',
981 trigger: e.trigger,
982 before: r.tokensBefore || S.ctx,
983 after: r.tokensAfter || 0,
984 outputTokens: u ? u.output_tokens || 0 : 0,
985 readTokens: u ? u.cache_read_input_tokens || 0 : 0,
986 wroteTokens: u ? u.cache_creation_input_tokens || 0 : 0,
987 usd: u ? requestCost(u, S.model, ttlMin()) : 0,
988 warm: before.kind === 'warm' || before.kind === 'cooling' || before.kind === 'kept',
989 })
990 return r
991 })
992
993 on('session.measure', async ($, e, next) => {
994 if (e.context && e.context.tokens) {
995 S.ctx = e.context.tokens
996 if (lastBlock && blockOverridden(lastBlock, await $.clock.now(), S.ctx)) {
997 const b = lastBlock
998 lastBlock = null
999 await recordEvent($, { at: await $.clock.now(), kind: 'idle-compact', v: VERSION, outcome: 'overridden', trigger: 'auto', silentMin: 0, ctx: b.ctx })
1000 $.ui.toast('Claude Code compacted anyway: the block did not hold. See /cache stats.', { timeoutMs: 10000 })
1001 }
1002 }
1003 if (e.context && e.context.window) S.window = e.context.window
1004 if (e.cost) S.costUsd = e.cost.usd
1005 if (Array.isArray(e.rateLimits) && e.rateLimits.length) S.rateLimits = e.rateLimits
1006 return next(e)
1007 })
1008
1009 // Waiting on the person: a permission prompt or a question
1010 on('tool.check', async ($, e, next) => {
1011 const d = await next(e)
1012 if (d && d.decision === 'ask' && !S.waitingSince) {
1013 now = await $.clock.now()
1014 S.waitingSince = now
1015 S.waitingFor = 'permission for ' + e.tool
1016 }
1017 return d
1018 })
1019
1020 on('tool.call', async ($, e, next) => {
1021 if (!e.agentId) S.tool = e.tool
1022 // Claude calling the handoff skill itself: this turn's answer is the handoff
1023 if (!e.agentId && e.tool === 'Skill' && HANDOFF_SKILL.test(String(e.skill || ''))) armHandoff(true)
1024 if (e.tool === 'AskUserQuestion') {
1025 now = await $.clock.now()
1026 S.waitingSince = now
1027 S.waitingFor = 'a question'
1028 }
1029 try {
1030 return await next(e)
1031 } finally {
1032 if (S.waitingSince) {
1033 S.waitingSince = 0
1034 S.waitingFor = ''
1035 }
1036 }
1037 })
1038
1039 // /session-handoff from anywhere (typed, the band, /handoff): watch for its answer
1040 on('command.run', async ($, e, next) => {
1041 if (HANDOFF_SKILL.test(String(e.command || ''))) armHandoff(false)
1042 return next(e)
1043 })
1044
1045 // /handoff runs the handoff and then asks to clear and continue (the band's
1046 // buttons draw only in the terminal); /handoff continue does the second half.
1047 // Both run off the hook: a command started inside a hook the session is
1048 // waiting on is refused.
1049 on('command.run', { command: ['handoff', 'ck-handoff'] }, async ($, e) => {
1050 now = await $.clock.now()
1051 const arg = String(e.args || '').trim().toLowerCase()
1052 if (arg === 'continue' || arg === 'go') {
1053 if (!H.text) return { text: `No handoff ready yet. Type /${names.handoff} to make one.` }
1054 $.clock.after(50, () => clearAndContinue($).catch(() => {}))
1055 return { text: 'Clearing this chat and continuing with the handoff.' }
1056 }
1057 H.askAfter = true
1058 $.clock.after(50, () => runHandoff($).catch(() => {}))
1059 return { text: 'Running the session handoff. When it finishes, you can clear this chat and continue with it.' }
1060 })
1061
1062 on('command.run', { command: ['cache', 'ck-cache'] }, async ($, e) => {
1063 now = await $.clock.now()
1064 const [key, value] = String(e.args || '').trim().toLowerCase().split(/\s+/)
1065 if (key === 'stats') return { text: await statsText($) }
1066 if (key === 'compact') {
1067 await refreshCompactParams($)
1068 return { text: compactText() }
1069 }
1070 if (key === 'reset') {
1071 Object.assign(settings, DEFAULTS)
1072 } else if (key === 'ttl') {
1073 settings.ttlMin = value === '5' ? 5 : value === '60' ? 60 : 0
1074 } else if (key === 'guard' || key === 'alerts') {
1075 settings[key] = value !== 'off'
1076 } else if (key === 'big') {
1077 const n = parseTokens(value)
1078 if (n) settings.bigTokens = n
1079 } else if (key === 'horizon') {
1080 const n = Math.round(Number(value))
1081 if (value === 'auto') settings.horizonTurns = 0
1082 else if (n > 0) settings.horizonTurns = Math.min(500, n)
1083 S.advisedCtx = 0
1084 if (compactCache) compactCache.at = 0 // recompute with the new horizon
1085 } else if (key === 'endcompact') {
1086 settings.endCompactTokens = value === 'off' ? 0 : parseTokens(value) || settings.endCompactTokens
1087 } else if (key === 'idleguard') {
1088 settings.idleGuard = value !== 'off'
1089 } else if (key === 'autocompact') {
1090 settings.autoCompactTokens = value === 'off' ? 0 : parseTokens(value) || settings.autoCompactTokens
1091 }
1092 if (key) {
1093 await $.store.set('settings', settings)
1094 $.ui.invalidate('ui.render')
1095 }
1096 return { text: statusText() }
1097 })
1098
1099 on('command.run', { command: ['keepwarm', 'ck-keepwarm'] }, async ($, e) => {
1100 now = await $.clock.now()
1101 const arg = String(e.args || '').trim().toLowerCase()
1102 if (arg === 'off' || (arg === '' && S.keepWarm)) {
1103 stopKeepWarm($, 'turned off')
1104 } else {
1105 const hours = Number(arg) > 0 ? Math.min(24, Number(arg)) : settings.keepWarmHours
1106 startKeepWarm($, hours)
1107 }
1108 await heartbeat($, true)
1109 $.ui.invalidate('ui.render')
1110 return {}
1111 })
1112
1113 on('command.run', { command: ['board', 'ck-board'] }, async ($, e) => {
1114 now = await $.clock.now()
1115 await heartbeat($, true)
1116 await readFleet($)
1117 const surface = await $.session.surface()
1118 if (!surface) return { text: boardText() }
1119 paneOpen = true
1120 await $.ui.open({ id: PANE, title: 'Sessions', focus: true, closeOnEscape: true })
1121 return {}
1122 })
1123
1124 on('ui.close', async ($, e, next) => {
1125 if (e.id === PANE) paneOpen = false
1126 return next(e)
1127 })
1128
1129 // The band above the prompt: this session's cache at a glance
1130 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1131 const below = await next(e)
1132 if (e.props && e.props.hasSurvey) return below
1133 if (!S.lastActivity && !S.keepWarm) return below
1134 const { Box, Text, Button } = $.ui.resolve(e)
1135 const hb = myHeartbeat()
1136 const st = cacheState(hb)
1137 const ttl = ttlMin()
1138 const big = S.ctx >= settings.bigTokens
1139 const parts = []
1140 if (st.kind === 'kept') parts.push(Text({ color: 'cyan', children: [`◆ kept warm · ${S.pings} ping${S.pings === 1 ? '' : 's'} ${money(S.pingUsd)} · until ${clock(S.keepWarmUntil)}`] }))
1141 else if (st.kind === 'warm') parts.push(Text({ color: 'green', children: [`● cache warm ${minutes(st.left)}`] }))
1142 else if (st.kind === 'cooling') parts.push(Text({ color: 'yellow', bold: true, children: [`◐ cache cools in ${minutes(st.left)}`] }))
1143 else if (st.kind === 'cold') parts.push(Text(big ? { color: 'red', bold: true, children: [`○ cache cold ${minutes(-st.left)}`] } : { dimColor: true, children: [`○ cache cold ${minutes(-st.left)}`] }))
1144 parts.push(Text({ dimColor: true, children: [` │ ctx ${tokens(S.ctx)}`] }))
1145 if (st.kind === 'cold' && big) parts.push(Text({ color: 'red', children: [` │ next send rewrites it ≈ ${money(rewriteCost(S.ctx, S.model, ttl))}`] }))
1146 else parts.push(Text({ dimColor: true, children: [` │ rewrite ≈ ${money(rewriteCost(S.ctx, S.model, ttl))}`] }))
1147 if (S.rateLimits.length) parts.push(Text(limitTone(S.rateLimits, { children: [' │ ' + limitsText(S.rateLimits)] })))
1148 parts.push(Text({ dimColor: true, children: [` │ session ${money(S.costUsd)}`] }))
1149 if (S.coldRestarts.length) parts.push(Text({ dimColor: true, children: [` │ ${S.coldRestarts.length} cold restart${S.coldRestarts.length === 1 ? '' : 's'} ${money(S.coldRestarts.reduce((a, c) => a + c.usd, 0))}`] }))
1150 const row = [Box({ flexDirection: 'row', children: parts })]
1151 // Offered whenever the cache is still alive (warm or cooling), whatever its size
1152 // and however long it has left: a cold cache can't be kept, a ping would rewrite it.
1153 if (st.kind === 'warm' || st.kind === 'cooling') {
1154 row.push(Button({ key: 'keepwarm', label: `keep warm ${settings.keepWarmHours}h`, hotkey: '1', plain: true, onPress: async () => { now = await $.clock.now(); startKeepWarm($, settings.keepWarmHours); await heartbeat($, true); $.ui.invalidate('ui.render') } }))
1155 } else if (st.kind === 'kept') {
1156 row.push(Button({ key: 'keepwarm', label: 'stop warm', hotkey: '1', plain: true, onPress: async () => { now = await $.clock.now(); stopKeepWarm($, 'turned off'); await heartbeat($, true); $.ui.invalidate('ui.render') } }))
1157 }
1158 const left = Box({ flexDirection: 'row', columnGap: 2, children: row })
1159 // Right edge: one click runs /session-handoff (h while the band has the focus;
1160 // a letter never fires from the prompt box, unlike a digit). Once a handoff
1161 // is in, c clears this chat and sends it as the fresh chat's first prompt.
1162 const busy = handoffPending || H.armed
1163 const handoff = Button({ key: 'handoff', label: handoffPending ? 'handoff queued' : H.armed ? 'handoff running' : 'handoff', hotkey: 'h', plain: true, dimColor: busy, onPress: () => runHandoff($) })
1164 let right = handoff
1165 if (H.text || H.continuing) {
1166 const go = Button({ key: 'continue', label: H.continuing ? 'clearing' : 'clear and continue', hotkey: 'c', plain: true, onPress: () => clearAndContinue($) })
1167 right = Box({ flexDirection: 'row', columnGap: 2, children: [go, handoff] })
1168 }
1169 const mine = Box({ flexDirection: 'row', justifyContent: 'space-between', width: '100%', columnGap: 2, children: [left, right] })
1170 return Box({ flexDirection: 'column', children: below ? [mine, below] : [mine] })
1171 })
1172
1173 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
1174 if (e.requestId !== PANE) return next(e)
1175 return drawBoard($, e)
1176 })
1177
1178 // The band is terminal-only. The footer also draws in the Desktop app, so it
1179 // carries a short label, only when there's something to act on.
1180 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
1181 const label = footerLabel()
1182 if (!label) return next(e)
1183 const modes = Array.isArray(e.props && e.props.modes) ? e.props.modes : []
1184 return next({ ...e, props: { ...e.props, modes: [...modes, label] } })
1185 })
1186}
1187
1188function footerLabel() {
1189 const parts = []
1190 const st = cacheState(myHeartbeat())
1191 const big = S.ctx >= settings.bigTokens
1192 if (st.kind === 'kept') parts.push('cache kept warm')
1193 else if (st.kind === 'cooling' && big) parts.push(`cache cools in ${minutes(st.left)} · /keepwarm`)
1194 else if (st.kind === 'cold' && big) parts.push(`cache cold · rewrite ≈ ${money(rewriteCost(S.ctx, S.model, ttlMin()))}`)
1195 const high = S.rateLimits.filter((l) => (l.percentUsed || 0) >= 80)
1196 if (high.length) parts.push(limitsText(high))
1197 if (H.text) parts.push(`handoff ready · /${names.handoff} continue`)
1198 return parts.join(' · ')
1199}
1200hooks/pricing.mjs 96 lines1// Shared source. Copied into each mod's hooks/ folder by _dev/sync-shared.mjs.
2// Edit it here, then run: node mods/_dev/sync-shared.mjs
3
4// Dollars per million tokens, from the Claude API pricing table (cached 2026-09-25).
5// Cache writes cost 1.25x input on the 5-minute TTL and 2x input on the 1-hour TTL.
6const TABLE = [
7 ['fable-5-1', { input: 10, output: 50, read: 0.25 }],
8 ['mythos-5-1', { input: 10, output: 50, read: 0.25 }],
9 ['fable-5', { input: 10, output: 50, read: 1.0 }],
10 ['opus-5-5', { input: 4, output: 20, read: 0.2 }],
11 ['opus-5', { input: 5, output: 25, read: 0.5 }],
12 ['opus-4-8', { input: 5, output: 25, read: 0.5 }],
13 ['opus-4-7', { input: 5, output: 25, read: 0.5 }],
14 ['opus-4-6', { input: 5, output: 25, read: 0.5 }],
15 ['sonnet-5-5', { input: 2, output: 10, read: 0.2 }],
16 ['sonnet-5', { input: 2, output: 10, read: 0.2 }],
17 ['sonnet-4-6', { input: 3, output: 15, read: 0.3 }],
18 ['haiku-4-5', { input: 1, output: 5, read: 0.1 }],
19]
20
21const FAMILY = {
22 fable: 'fable-5-1',
23 mythos: 'mythos-5-1',
24 opus: 'opus-5-5',
25 sonnet: 'sonnet-5-5',
26 haiku: 'haiku-4-5',
27}
28
29export function normalizeModel(model) {
30 return String(model || '')
31 .toLowerCase()
32 .replace(/^claude-/, '')
33 .replace(/\[.*?\]/g, '')
34 .replace(/-\d{8}$/, '')
35 .trim()
36}
37
38export function priceFor(model) {
39 const id = normalizeModel(model)
40 for (const [key, price] of TABLE) {
41 if (id === key || id.startsWith(key + '-') || id.startsWith(key)) {
42 // 'opus-5' must not swallow 'opus-5-5': the table lists longer ids first
43 return { id: key, ...price }
44 }
45 }
46 for (const [family, key] of Object.entries(FAMILY)) {
47 if (id.includes(family)) {
48 const found = TABLE.find(([k]) => k === key)
49 return { id: key, ...found[1] }
50 }
51 }
52 return { id: 'opus-5-5', input: 4, output: 20, read: 0.2 }
53}
54
55export function writeRate(model, ttlMinutes) {
56 const p = priceFor(model)
57 return p.input * (ttlMinutes >= 60 ? 2 : 1.25)
58}
59
60// usage: { input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens }
61export function requestCost(usage, model, ttlMinutes = 60) {
62 if (!usage) return 0
63 const p = priceFor(model || usage.model)
64 const w = writeRate(model || usage.model, ttlMinutes)
65 return (
66 ((usage.input_tokens || 0) * p.input +
67 (usage.cache_read_input_tokens || 0) * p.read +
68 (usage.cache_creation_input_tokens || 0) * w +
69 (usage.output_tokens || 0) * p.output) /
70 1e6
71 )
72}
73
74export function rewriteCost(tokens, model, ttlMinutes = 60) {
75 return ((tokens || 0) * writeRate(model, ttlMinutes)) / 1e6
76}
77
78export function readCost(tokens, model) {
79 return ((tokens || 0) * priceFor(model).read) / 1e6
80}
81
82export function totalInput(usage) {
83 if (!usage) return 0
84 return (
85 (usage.input_tokens || 0) +
86 (usage.cache_read_input_tokens || 0) +
87 (usage.cache_creation_input_tokens || 0)
88 )
89}
90
91// Share of a request's input served from the cache, 0 to 1.
92export function cachedShare(usage) {
93 const total = totalInput(usage)
94 return total > 0 ? (usage.cache_read_input_tokens || 0) / total : 0
95}
96hooks/fmt.mjs 70 lines1// Shared source. Copied into each mod's hooks/ folder by _dev/sync-shared.mjs.
2
3export function tokens(n) {
4 const v = Number(n) || 0
5 if (v >= 1e6) return (v / 1e6).toFixed(v >= 1e7 ? 0 : 1) + 'M'
6 if (v >= 1e3) return (v / 1e3).toFixed(v >= 1e5 ? 0 : 1) + 'k'
7 return String(Math.round(v))
8}
9
10export function usd(n) {
11 const v = Number(n) || 0
12 if (v === 0) return '$0'
13 if (v < 0.01) return '<$0.01'
14 if (v < 10) return '$' + v.toFixed(2)
15 return '$' + v.toFixed(0)
16}
17
18export function duration(ms) {
19 const s = Math.max(0, Math.round((Number(ms) || 0) / 1000))
20 if (s < 60) return s + 's'
21 const m = Math.floor(s / 60)
22 if (m < 60) return m + 'm' + String(s % 60).padStart(2, '0') + 's'
23 const h = Math.floor(m / 60)
24 return h + 'h' + String(m % 60).padStart(2, '0') + 'm'
25}
26
27export function minutes(ms) {
28 const m = Math.round((Number(ms) || 0) / 60000)
29 if (m <= 60) return m + 'm'
30 return Math.floor(m / 60) + 'h' + String(m % 60).padStart(2, '0') + 'm'
31}
32
33export function clock(ts) {
34 const d = new Date(ts)
35 let h = d.getHours()
36 const ampm = h >= 12 ? 'pm' : 'am'
37 h = h % 12 || 12
38 return h + ':' + String(d.getMinutes()).padStart(2, '0') + ampm
39}
40
41const SPARKS = '▁▂▃▄▅▆▇█'
42
43export function sparkline(values, width = 12) {
44 const vals = values.slice(-width)
45 if (vals.length === 0) return ''
46 const max = Math.max(...vals, 1)
47 return vals.map((v) => SPARKS[Math.min(7, Math.floor((v / max) * 7.999))]).join('')
48}
49
50export function bar(fraction, width = 20, full = '█', empty = '░') {
51 const f = Math.max(0, Math.min(1, Number(fraction) || 0))
52 const n = Math.round(f * width)
53 return full.repeat(n) + empty.repeat(width - n)
54}
55
56export function clip(text, max) {
57 const s = String(text ?? '').replace(/\s+/g, ' ').trim()
58 return s.length > max ? s.slice(0, Math.max(0, max - 1)) + '…' : s
59}
60
61export function pad(text, width) {
62 const s = String(text ?? '')
63 return s.length >= width ? s.slice(0, width) : s + ' '.repeat(width - s.length)
64}
65
66export function basename(path) {
67 const parts = String(path || '').split(/[\\/]+/).filter(Boolean)
68 return parts[parts.length - 1] || String(path || '')
69}
70hooks/privacy.mjs 337 lines1// Masks secrets, personal details, and business figures for screen recording.
2// Pure functions: no mods API calls here. The same file ships in each mod that
3// draws text, since a mod may import only its own files.
4
5const DOTS = '••••••••'
6const HIDE = '•••'
7const HIDDEN_OUTPUT = '••• hidden while recording'
8
9const SECRET_PATTERNS = [
10 /sk-ant-[A-Za-z0-9_-]{16,}/g,
11 /\bsk-(?:proj-|live-|test-|svcacct-)?[A-Za-z0-9_-]{20,}/g,
12 /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
13 /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
14 /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
15 /\bAIza[0-9A-Za-z_-]{30,}/g,
16 /\bAKIA[0-9A-Z]{16}\b/g,
17 /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
18 /\b(?:hf|r8|pk_live|sk_live|rk_live|whsec|pat|key)_[A-Za-z0-9]{20,}/g,
19 /\bBearer\s+[A-Za-z0-9._~+/-]{20,}=*/gi,
20]
21
22// NAME=value or "name": "value" where the name looks like a credential
23const ASSIGNMENT =
24 /\b([A-Za-z0-9_.-]*(?:API[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|CLIENT[_-]?SECRET|AUTH)[A-Za-z0-9_.-]*)(["']?\s*[=:]\s*["']?)([^\s"'`,;}{]{6,})/gi
25
26const EMAIL_SRC = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}'
27const EMAIL = new RegExp(`\\b${EMAIL_SRC}\\b`, 'g')
28
29// People: "Jane Doe <jane@x.com>", "displayName": "Jane Doe", and From:/Attendees: lines
30const NAME_WORD = "[A-Z][\\p{L}'’.-]*"
31const DISPLAY_NAME = new RegExp(`(["']?)(${NAME_WORD}(?:[ \\t]+${NAME_WORD}){0,3})\\1([ \\t]*<[ \\t]*${EMAIL_SRC}[ \\t]*>)`, 'gu')
32const PERSON_KEY =
33 /("(?:displayName|display_name|fullName|full_name|firstName|first_name|lastName|last_name|givenName|given_name|familyName|family_name|real_name|realName|senderName|sender_name|authorName|author_name|username|user_name)"\s*:\s*")([^"]{1,80})(")/g
34const PERSON_LINE = /^([ \t>*-]*(?:From|To|Cc|Bcc|Reply-To|Attendees?|Organizer|Invitees?|Guests?|Assignees?|Sender|Owner)[ \t]*:[ \t]*)(\S.*)$/gm
35
36// Personal details
37const PHONE = /(?<![\w.])(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?![\w.])/g
38const PHONE_INTL = /(?<![\w.])\+\d{1,3}[\s.-]?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}(?![\w.])/g
39const SSN = /(?<![\w-])\d{3}-\d{2}-\d{4}(?![\w-])/g
40const EIN = /(?<![\w-])\d{2}-\d{7}(?![\w-])/g
41const CARD = /(?<![\w-])(?:4\d{3}|5[1-5]\d{2}|2[2-7]\d{2}|3[47]\d{2}|6(?:011|5\d{2}))(?:[ -]?\d{2,4}){3,4}(?![\w-])/g
42const IBAN = /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\b/g
43const ACCOUNT = /\b((?:routing|account|acct|a\/c|aba|swift|bic|iban|passport|driver'?s license)(?:\s*(?:number|no\.?|num|#))?\s*[:#]?\s*)(\d[\d -]{3,}\d)/gi
44const CARD_ENDING = /\b((?:ending(?:\s+in)?|last\s+(?:4|four)(?:\s+digits)?)\s*[:#]?\s*)(\d{4})\b/gi
45const STREET = /\b\d{1,6}\s+(?:[NSEW]\.?\s+)?(?:[A-Z][\p{L}'.-]*\s+){1,3}(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Way|Court|Ct|Place|Pl|Parkway|Pkwy|Highway|Hwy|Circle|Cir|Terrace|Ter|Trail|Trl|Square|Sq)\b\.?(?:,?\s+(?:Apt|Apartment|Suite|Ste|Unit|#)\.?\s*[\w-]+)?/gu
46const PO_BOX = /\bP\.?\s?O\.?\s+Box\s+\d+/gi
47const STATE_ZIP = /(,\s*(?:A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY]))\s+\d{5}(?:-\d{4})?\b/g
48const BIRTH = /\b((?:DOB|D\.O\.B\.|date of birth|birth\s?date|birthday|born(?: on)?)\s*[:-]?\s*)([A-Za-z0-9 ,/.-]{4,20}\d)/gi
49const IPV4 = /(?<![\w.])((?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3})(?![\w.])/g
50
51// Money in any common currency
52const MONEY = /(?:US|CA|AU)?[$€£¥]\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion)\b)?/g
53const MONEY_WORD = /(?<![\w.])(?:(?:USD|EUR|GBP|CAD|AUD)\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion)\b)?|\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion))?\s?(?:USD|EUR|GBP|CAD|AUD|dollars|bucks)\b)/gi
54
55// Figures near business words: "MRR is 84k", "40% margin", "profit share 20%"
56const FIN_WORD =
57 /\b(?:revenues?|mrr|arr|gmv|profits?|profit share|margins?|ebitda|income|earnings|payroll|salar(?:y|ies)|wages?|compensation|comp|bonus(?:es)?|equity|stakes?|ownership|valuation|runway|burn(?: rate)?|cash(?:flow)?|balances?|budgets?|spend(?:ing)?|pric(?:e|es|ing)|fees?|invoices?|payouts?|distributions?|dividends?|tax(?:es)?|sales|ltv|cac|aov|sponsor(?:s|ships?)?|cpm|rpm|retainers?|commissions?|royalt(?:y|ies)|refunds?|expenses?|debts?|loans?|funding|investments?|deals?|net|gross|paid|pays?|owed?|costs?)\b/gi
58const FIGURE = /(?<![\w.])\d[\d,]*(?:\.\d+)?(?:\s?(?:%|percent\b|[kKmMbB]\b|bn\b|million\b|thousand\b|billion\b|x\b))?/g
59const BIG_FIGURE = /(?<![\w.])(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?\s?(?:%|percent\b|[kKmMbB]\b|bn\b|million\b|thousand\b|billion\b))/g
60
61// Single names that are also ordinary words, and names that stay visible (the channel's own)
62const COMMON_WORDS = new Set(
63 'Will Mark Grant Bill Rich Art Hope Faith Joy May June April August Summer Rose Page Chase Hunter Max Ray Dawn Sky Brook Lane Dean Gene Guy Frank Sterling Major Price Young King Long Little Wood Hill Stone Field Ford Hall Bell Rice Banks Case Cook Fox Gray Green Brown White Black Day Lee West North South Love Church Park Street Bishop Mason Miller Baker Carter Cole Wells Hart Moon Star Van Von De Del La Le Da Di St Mr Mrs Ms Dr Jr Sr Claude Code Team Support Admin Info Hello The And Ai Slack Gmail Google Calendar Meeting Sync Notes Update Review Weekly Daily Monday Tuesday Wednesday Thursday Friday Saturday Sunday'.split(' '),
64)
65const ROLE_LOCAL_PARTS = /^(?:info|hello|hi|team|support|help|admin|noreply|no-reply|donotreply|contact|sales|billing|accounts?|notifications?|news|newsletter|marketing|office|mail|security|privacy|legal|press|jobs|careers|hr|ops|dev|bot|alerts?|updates?|calendar|invites?)$/i
66
67// Files that stay closed while recording. Add your own folders and file names
68// in ~/.claude/mods-data/recording-mode/config.json ("privatePaths"); /rec config makes one.
69const PRIVATE_PATHS = [
70 /(^|[\\/\s"'`])\.env(\.[A-Za-z0-9_-]+)?(?=$|[\s"'`;|&)])/i,
71 /\.credentials\.json/i,
72 /[\\/]\.claude\.json\b/i,
73 /\.claude[\\/]projects[\\/][^\\/\s"'`]+[\\/]memory/i,
74 /(^|[\\/\s"'`])\.(?:ssh|aws|gnupg)(?=$|[\\/])/i,
75 /\bid_(?:rsa|ed25519|ecdsa)\b/i,
76 /(^|[\\/\s"'`])\.(?:netrc|npmrc|pypirc)\b/i,
77 /\.(?:pem|p12|pfx)\b/i,
78 /taxes?[-_ ]?20\d\d/i,
79 /[\\/_-](?:payroll|invoices?|contracts?|financials?|finances?|budgets?|forecasts?|cap[-_ ]?table|term[-_ ]?sheets?|offer[-_ ]?letters?|business[-_ ]?plans?)(?=$|[\\/._\s"'`;|&)-])/i,
80 /(^|[\s"'`])(?:payroll|invoices?|contracts?|financials?|finances?|budgets?|forecasts?|business[-_ ]?plans?)(?=[\\/.])/i,
81]
82
83// Tools whose results are business data: drawn as hidden while recording
84const BUSINESS_TOOL =
85 /(?:^|__|_)(?:clickup|slack|gmail|outlook|notion|asana|linear|jira|clay|qbo|quickbooks|xero|fireflies|hubspot|salesforce|stripe|calendar|gcal|google_drive|drive|sheets)|search_threads|get_thread|get_message|list_drafts|get_draft|read_file_content|download_file_content|search_files|list_recent_files|get_file_metadata|profit_loss|cash_flow|balance_sheet|payroll|session_transcripts|export_transcript/i
86const BUSINESS_COMMAND = /\bgws(?:\.cmd|\.exe)?\b|fireflies|quickbooks/i
87// Finance tools stay closed while recording
88const FINANCE_TOOL = /__(?:qbo_|quickbooks|profit_loss|cash_flow|balance_sheet|benchmarking_quickbooks|money_onboarding|company_info)/i
89
90function escapeRegExp(s) {
91 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
92}
93
94// Values from .env files: long enough to be secret, and not plain words or numbers.
95export function secretValuesFromEnv(text) {
96 const values = []
97 for (const line of String(text || '').split(/\r?\n/)) {
98 const m = line.match(/^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.*)\s*$/)
99 if (!m) continue
100 let v = m[1].trim()
101 if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
102 if (v.length < 8) continue
103 if (/^(true|false|null|none|yes|no)$/i.test(v)) continue
104 if (/^\d+(\.\d+)?$/.test(v)) continue
105 if (/your[-_ ]?(key|token|secret)|here$|^<.*>$|^xxx/i.test(v)) continue
106 values.push(v)
107 }
108 return [...new Set(values)].sort((a, b) => b.length - a.length)
109}
110
111// The forms of one person's name that get masked: the full name, its slug, and each part that isn't an ordinary word
112function nameForms(full, keep) {
113 const clean = String(full || '').replace(/\s+/g, ' ').trim()
114 if (!clean || clean.length > 60 || keep.has(clean)) return []
115 const forms = new Set()
116 const parts = clean.split(/[ -]/).filter((p) => /^[A-Z][\p{L}'’.]*$/u.test(p) && p.length >= 2)
117 if (parts.length >= 2) {
118 forms.add(clean)
119 forms.add(clean.toLowerCase())
120 forms.add(clean.toLowerCase().replace(/ /g, '-'))
121 forms.add(clean.toLowerCase().replace(/ /g, '_'))
122 }
123 for (const p of parts) if (!COMMON_WORDS.has(p) && !keep.has(p)) forms.add(p)
124 return [...forms]
125}
126
127function namesFromEmail(email) {
128 const local = email.split('@')[0]
129 if (ROLE_LOCAL_PARTS.test(local)) return null
130 const parts = local.split(/[._+-]/).filter((p) => /^[a-z]{2,}$/i.test(p))
131 if (parts.length < 2 || parts.length > 4) return null
132 return parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join(' ')
133}
134
135// Masks numbers in the same clause as a business word
136function maskFigures(text, strict) {
137 if (strict) text = text.replace(BIG_FIGURE, HIDE)
138 const ranges = []
139 for (const m of text.matchAll(FIN_WORD)) {
140 const s = m.index
141 const e = s + m[0].length
142 let a = Math.max(0, s - 30)
143 let b = Math.min(text.length, e + 45)
144 const before = text.slice(a, s)
145 const stop = Math.max(before.lastIndexOf('\n'), before.lastIndexOf(';'), before.search(/[.!?]\s[^.!?]*$/))
146 if (stop >= 0) a += stop + 1
147 const after = text.slice(e, b)
148 const end = after.search(/\n|;|[.!?](?:\s|$)/)
149 if (end >= 0) b = e + end
150 ranges.push([a, b])
151 }
152 if (!ranges.length) return text
153 return text.replace(FIGURE, (fig, offset) => {
154 if (/^(?:19|20)\d\d$/.test(fig)) return fig // a year
155 return ranges.some(([a, b]) => offset >= a && offset < b) ? HIDE : fig
156 })
157}
158
159function luhn(digits) {
160 let sum = 0
161 for (let i = 0; i < digits.length; i++) {
162 let d = Number(digits[digits.length - 1 - i])
163 if (i % 2 === 1) {
164 d *= 2
165 if (d > 9) d -= 9
166 }
167 sum += d
168 }
169 return sum % 10 === 0
170}
171
172function publicIp(ip) {
173 const [a, b] = ip.split('.').map(Number)
174 if (a === 10 || a === 127 || a === 0 || ip === '255.255.255.255') return false
175 if (a === 192 && b === 168) return false
176 if (a === 172 && b >= 16 && b <= 31) return false
177 if (a === 169 && b === 254) return false
178 return true
179}
180
181// Your own name and its parts stay visible
182function keepSet(keepNames) {
183 const keep = new Set()
184 for (const n of keepNames || []) {
185 const clean = String(n || '').replace(/\s+/g, ' ').trim()
186 if (!clean) continue
187 keep.add(clean)
188 keep.add(clean.toLowerCase())
189 keep.add(clean.toLowerCase().replace(/ /g, '-'))
190 for (const part of clean.split(/[ -]/)) if (part) keep.add(part)
191 }
192 return keep
193}
194
195// strict: also every large figure (1,250 / 18% / 40k) wherever it appears
196// names: people to mask; keepNames: names that stay visible (yours)
197export function makeMasker({ strict = false, values = [], names = [], keepNames = [] } = {}) {
198 const KEEP_NAMES = keepSet(keepNames)
199 const valueRe = values.length
200 ? new RegExp(values.map(escapeRegExp).join('|'), 'g')
201 : null
202 // Names: the roster passed in plus any learned from emails and contact fields. Memory only.
203 const people = new Set()
204 let nameRe = null
205 let dirty = false
206 const learn = (full) => {
207 if (people.size > 2000) return
208 for (const f of nameForms(full, KEEP_NAMES)) {
209 if (!people.has(f)) {
210 people.add(f)
211 dirty = true
212 }
213 }
214 }
215 for (const n of names) learn(n)
216 const nameRegex = () => {
217 if (dirty) {
218 const list = [...people].sort((a, b) => b.length - a.length).map(escapeRegExp)
219 nameRe = list.length ? new RegExp(`(?<![\\p{L}\\p{N}_])(?:${list.join('|')})(?:['’]s)?(?![\\p{L}\\p{N}_])`, 'gu') : null
220 dirty = false
221 }
222 return nameRe
223 }
224
225 return function mask(text) {
226 if (typeof text !== 'string' || text.length === 0) return text
227 let out = text
228 if (valueRe) out = out.replace(valueRe, DOTS)
229 for (const re of SECRET_PATTERNS) out = out.replace(re, DOTS)
230 out = out.replace(ASSIGNMENT, (_, name, sep) => name + sep + DOTS)
231
232 // people, learned before the emails that name them are masked
233 for (const m of out.matchAll(EMAIL)) {
234 const n = namesFromEmail(m[0])
235 if (n) learn(n)
236 }
237 out = out.replace(DISPLAY_NAME, (_, q, name, addr) => {
238 learn(name)
239 return KEEP_NAMES.has(name) ? q + name + q + addr : HIDE + addr
240 })
241 out = out.replace(PERSON_KEY, (_, head, value, tail) => {
242 learn(value)
243 return head + (KEEP_NAMES.has(value) ? value : HIDE) + tail
244 })
245 out = out.replace(PERSON_LINE, (line, label, value) => (KEEP_NAMES.has(value.trim()) ? line : label + HIDE))
246 out = out.replace(EMAIL, '•••@•••')
247 const re = nameRegex()
248 if (re) out = out.replace(re, HIDE)
249
250 // personal details
251 out = out.replace(CARD, (m) => {
252 const digits = m.replace(/\D/g, '')
253 return digits.length >= 13 && digits.length <= 19 && luhn(digits) ? '•••• •••• •••• ••••' : m
254 })
255 out = out.replace(SSN, '•••-••-••••')
256 out = out.replace(EIN, '••-•••••••')
257 out = out.replace(IBAN, HIDE)
258 out = out.replace(ACCOUNT, (_, head) => head + HIDE)
259 out = out.replace(CARD_ENDING, (_, head) => head + '••••')
260 out = out.replace(BIRTH, (_, head) => head + HIDE)
261 out = out.replace(STREET, HIDE)
262 out = out.replace(PO_BOX, HIDE)
263 out = out.replace(STATE_ZIP, (_, head) => head + ' •••••')
264 out = out.replace(PHONE, '•••-•••-••••')
265 out = out.replace(PHONE_INTL, '+•• •••')
266 out = out.replace(IPV4, (ip) => (publicIp(ip) ? '•••.•••.•••.•••' : ip))
267
268 // business figures
269 out = out.replace(MONEY, '$•••')
270 out = out.replace(MONEY_WORD, HIDE)
271 out = maskFigures(out, strict)
272 return out
273 }
274}
275
276// Masks every string inside plain data, keeping the shape.
277export function deepMask(value, mask, depth = 0) {
278 if (depth > 12) return value
279 if (typeof value === 'string') return mask(value)
280 if (Array.isArray(value)) return value.map((v) => deepMask(v, mask, depth + 1))
281 if (value && typeof value === 'object') {
282 const proto = Object.getPrototypeOf(value)
283 if (proto !== Object.prototype && proto !== null) return value
284 const out = {}
285 for (const [k, v] of Object.entries(value)) out[k] = deepMask(v, mask, depth + 1)
286 return out
287 }
288 return value
289}
290
291// Hides the readable text inside a business tool's result, keeping short tags (type: 'text') so the row still draws.
292export function hideText(s) {
293 if (typeof s !== 'string' || s.length === 0) return s
294 return s.length > 24 || /\s/.test(s) ? HIDDEN_OUTPUT : s
295}
296
297// extra: your own folder or file names from the config file, matched anywhere in a path, any case
298export function privatePathHit(text, extra = []) {
299 const s = String(text || '')
300 for (const re of PRIVATE_PATHS) {
301 const m = s.match(re)
302 if (m) return m[0].trim().replace(/^["'`\\/_-]/, '')
303 }
304 const flat = s.replace(/\\/g, '/').toLowerCase()
305 for (const item of extra) {
306 const needle = String(item || '').replace(/\\/g, '/').toLowerCase().trim()
307 if (needle && flat.includes(needle)) return String(item)
308 }
309 return null
310}
311
312// The strings in a tool call's arguments that could name a file or carry a command.
313export function toolCallTargets(e) {
314 const keys = ['file_path', 'path', 'pattern', 'glob', 'command', 'notebook_path', 'url']
315 const out = []
316 for (const k of keys) if (typeof e?.[k] === 'string') out.push(e[k])
317 return out
318}
319
320// Whether a tool's result is business data (mail, chat, tasks, files, calendar, finance)
321// extra: more tool or command names (any part of the name) whose results draw as hidden
322export function businessSource(tool, input, extra = []) {
323 const name = String(tool || '')
324 if (BUSINESS_TOOL.test(name)) return true
325 const cmd = input && typeof input.command === 'string' ? input.command : ''
326 const hay = (name + ' ' + cmd).toLowerCase()
327 if (extra.some((x) => x && hay.includes(String(x).toLowerCase()))) return true
328 const command = input && typeof input.command === 'string' ? input.command : ''
329 return command ? BUSINESS_COMMAND.test(command) : false
330}
331
332// extra: more tool names (any part of the name) that stay closed while recording
333export function financeTool(tool, extra = []) {
334 const name = String(tool || '')
335 return FINANCE_TOOL.test(name) || extra.some((x) => x && name.toLowerCase().includes(String(x).toLowerCase()))
336}
337hooks/ledger.mjs 94 lines1// The cache ledger: what keep warm cost and saved, what cold restarts cost and
2// how much of that keep warm could have saved. Pure functions, no `$`, so they
3// are tested on their own (node --test cache-keeper/tests/).
4//
5// T is the cache TTL. A ping fires at T minus a margin after the last request
6// and gives the cache another T. Prices come from pricing.mjs (API list prices;
7// on a subscription they are usage against plan limits, not a bill).
8
9import { rewriteCost, readCost, requestCost, cachedShare } from './pricing.mjs'
10
11const MIN = 60000
12export const MAX_KEEP_WARM_HOURS = 24
13
14// Same timing keepWarmStep uses: 8 minutes early on the 1-hour TTL, 90 s on 5 min
15export function pingMarginMs(ttlMin) {
16 return ttlMin >= 60 ? 8 * MIN : 90000
17}
18
19// Pings that would have carried the cache over an idle gap
20export function pingsNeeded(gapMs, ttlMin) {
21 const ttlMs = ttlMin * MIN
22 if (gapMs <= ttlMs) return 0
23 return Math.ceil((gapMs - ttlMs) / (ttlMs - pingMarginMs(ttlMin)))
24}
25
26// One ping over a context of ctxTokens. With pings measured in this session, use
27// their cost per cached token; otherwise a read of the context plus the ping's
28// own few tokens.
29export function pingCostUsd(ctxTokens, model, ttlMin, measured) {
30 if (measured && measured.readTokens > 0) return (measured.usd / measured.readTokens) * ctxTokens
31 return requestCost({ cache_read_input_tokens: ctxTokens, input_tokens: 60, output_tokens: 5 }, model, ttlMin)
32}
33
34// A cold restart: what it cost, and whether keep warm could have prevented it
35// for less. Categories are checked in order; only 'avoidable' claims a saving.
36export function classifyColdRestart({ gapMs, ttlMin, tokens, model, keepWarmOn = false, longestSleepMs = 0, measured }) {
37 const usd = rewriteCost(tokens, model, ttlMin)
38 const need = pingsNeeded(gapMs, ttlMin)
39 const keepWarmUsd = need * pingCostUsd(tokens, model, ttlMin, measured)
40 const base = { usd, pingsNeeded: need, keepWarmUsd, avoidableUsd: 0 }
41 if (keepWarmOn) return { ...base, category: 'keepwarm-failed' }
42 if (gapMs > MAX_KEEP_WARM_HOURS * 60 * MIN) return { ...base, category: 'too-long' }
43 if (longestSleepMs >= pingMarginMs(ttlMin)) return { ...base, category: 'asleep' }
44 if (keepWarmUsd >= usd) return { ...base, category: 'not-worth' }
45 return { ...base, category: 'avoidable', avoidableUsd: usd - keepWarmUsd }
46}
47
48// The first real request after a keep-warm stretch settles it. A hit saves the
49// rewrite only if the cache would have expired without the pings.
50export function settleKeepWarm({ episode, startedAt, ttlMin, usage, model }) {
51 const base = { pings: episode.pings, pingUsd: episode.usd, savedUsd: 0 }
52 if (cachedShare(usage) <= 0.8) return { ...base, outcome: 'failed' }
53 if (startedAt - episode.lastRealAt <= ttlMin * MIN) return { ...base, outcome: 'unneeded' }
54 const read = usage.cache_read_input_tokens || 0
55 return { ...base, outcome: 'saved', savedUsd: rewriteCost(read, model, ttlMin) - readCost(read, model) }
56}
57
58export function pruneEvents(events, cutoff) {
59 return events.filter((ev) => ev.at >= cutoff)
60}
61
62export function summarize(events, since = 0) {
63 const s = {
64 keepWarm: { pings: 0, pingUsd: 0, savedUsd: 0, wastedUsd: 0, netUsd: 0, saved: 0, unneeded: 0, failed: 0, abandoned: 0 },
65 cold: { count: 0, usd: 0, avoidableUsd: 0, byCategory: {} },
66 resume: { count: 0, usd: 0 },
67 }
68 for (const ev of events) {
69 if (ev.at < since) continue
70 if (ev.kind === 'keepwarm') {
71 const k = s.keepWarm
72 k.pings += ev.pings || 0
73 k.pingUsd += ev.pingUsd || 0
74 k.savedUsd += ev.savedUsd || 0
75 if (ev.outcome !== 'saved') k.wastedUsd += ev.pingUsd || 0
76 if (ev.outcome in k) k[ev.outcome] += 1
77 } else if (ev.kind === 'cold') {
78 const c = s.cold
79 c.count += 1
80 c.usd += ev.usd || 0
81 c.avoidableUsd += ev.avoidableUsd || 0
82 const cat = (c.byCategory[ev.category] ||= { count: 0, usd: 0, avoidableUsd: 0 })
83 cat.count += 1
84 cat.usd += ev.usd || 0
85 cat.avoidableUsd += ev.avoidableUsd || 0
86 } else if (ev.kind === 'resume') {
87 s.resume.count += 1
88 s.resume.usd += ev.usd || 0
89 }
90 }
91 s.keepWarm.netUsd = s.keepWarm.savedUsd - s.keepWarm.pingUsd
92 return s
93}
94hooks/version.mjs 5 lines1// The version this code ships as; keep in step with .claude-plugin/plugin.json
2// (tests/version.test.mjs fails when they differ). /cache stats prints it, so a
3// chat that started before an update is easy to tell from a fresh one.
4export const VERSION = '1.6.1'
5hooks/endcompact.mjs 22 lines1// Keep warm stops at its time limit and the cache then expires, so a big chat
2// ends up rewritten at full price on the next message. Just before the last ping
3// the cache is still warm, which is the cheapest moment to compact it: the
4// compaction request reads the context at the cache price instead of rewriting
5// it. Pure function, no `$`, tested on its own (node --test cache-keeper/tests/).
6
7import { pingMarginMs } from './ledger.mjs'
8
9const MIN = 60000
10const AWAY_MS = 30 * MIN // same silence the idle guard uses
11
12export function endCompactDue({ keepWarmOn, working, ttlMin, ctx, threshold, lastActivity, now, keepWarmUntil, silentMs }) {
13 if (!keepWarmOn || working || !threshold || ctx < threshold || !lastActivity) return false
14 if (silentMs < AWAY_MS) return false
15 const ttlMs = ttlMin * MIN
16 const interval = ttlMs - pingMarginMs(ttlMin) // ping to ping
17 if (now >= lastActivity + ttlMs) return false // already expired: compacting would rewrite at full price
18 if (now >= keepWarmUntil) return true // the limit hit while the cache was still warm
19 if (now < lastActivity + interval) return false // cache is fresh, nothing to do yet
20 return now + interval >= keepWarmUntil // the ping after this one would not happen
21}
22hooks/keepwarmwindow.mjs 18 lines1// The keep-warm time limit slides: every real message restarts the window, so
2// "24 h" means 24 h after the last time someone was here, not 24 h from the
3// moment it was switched on. Pure function, tested on its own.
4export function slideUntil({ keepWarmOn, spanHours, now, until }) {
5 if (!keepWarmOn || !(spanHours > 0)) return until
6 return Math.max(until, now + spanHours * 3600000)
7}
8
9import { pingMarginMs } from './ledger.mjs'
10
11// A ping is due once the cache has gone ttl minus the margin without a request.
12// A turn that is merely waiting (for an answer, a permission, a long command) sends
13// no requests, so only a request actually out holds the ping back.
14export function pingDue({ requestOut, lastActivity, now, ttlMin }) {
15 if (requestOut || !lastActivity) return false
16 return now - lastActivity >= ttlMin * 60000 - pingMarginMs(ttlMin)
17}
18hooks/idleguard.mjs 49 lines1// Claude Code compacts a long chat by itself once nobody has written for about 54
2// minutes (built-in idle compaction, ~90% of the 1 h cache). With keep warm on
3// that throws away a cache we are paying to hold, so this vetoes it. Pure
4// functions, no `$`, tested on their own (node --test cache-keeper/tests/).
5
6const MIN = 60000
7
8// Over normal pauses, under the built-in moment, so a compaction after this much
9// silence is the idle one and not auto-compact during work.
10export const IDLE_SILENCE_MS = 30 * MIN
11
12export function shouldBlockCompact({ trigger, keepWarmOn, working, silentMs, enabled }) {
13 if (!enabled || trigger !== 'auto' || !keepWarmOn || working) return { block: false }
14 if (silentMs < IDLE_SILENCE_MS) return { block: false }
15 return { block: true, reason: 'idle-with-keep-warm' }
16}
17
18export function summarizeIdleBlocks(events, since = 0) {
19 const s = { blocked: 0, passed: 0, overridden: 0, legacy: 0, lastBlockedAt: 0 }
20 for (const ev of events) {
21 if (ev.kind !== 'idle-compact' || ev.at < since) continue
22 if (!ev.v) {
23 s.legacy += 1 // written before 1.3.2, when the veto was rejected
24 continue
25 }
26 if (ev.outcome === 'blocked') {
27 s.blocked += 1
28 s.lastBlockedAt = Math.max(s.lastBlockedAt, ev.at)
29 } else if (ev.outcome === 'passed') s.passed += 1
30 else if (ev.outcome === 'overridden') s.overridden += 1
31 }
32 return s
33}
34
35// What session.compact must return to veto: { skip } with a non-empty string
36// reason. Anything else (true, an empty string) is rejected by Claude Code and
37// the compaction runs anyway.
38export function vetoResult(decision) {
39 return { skip: `cache-keeper: ${decision.reason}, keep warm is on and nobody has written for a long time` }
40}
41
42// A veto that Claude Code ignored shows up as the context collapsing soon after
43// it. `blocked` is { at, ctx } of the last veto; within 10 minutes a context
44// under half of what it was means the compaction ran anyway.
45export function blockOverridden(blocked, nowMs, ctxNow) {
46 if (!blocked || !blocked.ctx) return false
47 return nowMs - blocked.at <= 10 * MIN && ctxNow < blocked.ctx * 0.5
48}
49hooks/compact.mjs 181 lines1// When does compacting a session pay for itself? Pure functions, no `$`, tested
2// on their own (node --test cache-keeper/tests/).
3//
4// Notation (per token, USD): r cache read, w cache write at the session's TTL,
5// o output. C is the context now, N the context right after compacting, s the
6// tokens of the summary the model writes.
7//
8// Compacting costs, with the cache warm:
9// C·r the compaction request reads the whole context from the cache
10// + s·o the model writes the summary
11// + N·w the first message afterwards writes the new, short prefix
12// (with the cache cold and a message about to be sent anyway, the C·w rewrite is
13// paid either way, so only s·o + N·w is extra).
14//
15// It saves, on every later message, (C - N)·rho, where rho = k·r + q·g: k reads
16// of the smaller context (a message is k model requests, each reading the whole
17// context), plus the share q of messages that arrive after a gap longer than the
18// TTL, each of which would rewrite C instead of N (g = w, or two pings' worth of
19// reads when keep warm is on). Break-even is cost / saving.
20//
21// Money only. A summary loses detail, and this does not price that.
22
23import { priceFor, writeRate } from './pricing.mjs'
24
25// Measured in the owner's transcripts (2026-10-08, 73 compactions): the first
26// request after a compaction has 76k tokens in total (median; the transcript's own
27// "post" count of 18k leaves out the system prompt, tools and instructions that
28// every request carries, about 52k at the start of a session), and 80% of it is
29// written rather than read. The rest are assumptions until the mod measures them.
30export const DEFAULT_PARAMS = {
31 summaryTokens: 10000, // assumed: not in transcripts
32 newCtxTokens: 76000, // measured, median of 73 (min 54k)
33 rewriteShare: 0.8, // measured, median of 73 (p25 0.67, p75 0.89)
34 gapShare: 0.1, // assumed prior; shrinks toward what the mod measures
35 horizonTurns: 15, // a setting: messages you expect to send from here
36 stepsPerTurn: 12, // measured: median of 68 sessions (overall 13.8 requests per prompt)
37}
38
39const MEASURED_MIN = 3
40const GAP_PRIOR_WEIGHT = 20 // messages worth of belief in the prior
41const GAP_MEASURED_MIN = 30
42const STEPS_PRIOR_WEIGHT = 10 // messages worth of belief in the prior
43const PINGS_PER_GAP = 2 // keep warm over a typical long gap
44const TOO_SMALL_FACTOR = 1.2 // under 1.2x the compacted size there is under 17% to cut
45
46function prices(model, ttlMin) {
47 const p = priceFor(model)
48 return { r: p.read / 1e6, w: writeRate(model, ttlMin) / 1e6, o: p.output / 1e6 }
49}
50
51// Per-message saving factor: the reads of every request, plus the cost of the
52// long gaps it removes
53function rho({ model, ttlMin, params, keepWarmOn }) {
54 const { r, w } = prices(model, ttlMin)
55 const g = keepWarmOn ? PINGS_PER_GAP * r : w
56 return params.stepsPerTurn * r + params.gapShare * g
57}
58
59export function compactionCostUsd({ ctx, model, ttlMin, params, warm, sendPending = false }) {
60 const { r, w, o } = prices(model, ttlMin)
61 const requestUsd = !warm && sendPending ? 0 : ctx * (warm ? r : w)
62 const outputUsd = params.summaryTokens * o
63 const rebuildUsd = params.newCtxTokens * params.rewriteShare * w
64 return { requestUsd, outputUsd, rebuildUsd, totalUsd: requestUsd + outputUsd + rebuildUsd }
65}
66
67export function savingPerTurnUsd({ ctx, model, ttlMin, params, keepWarmOn = false }) {
68 return Math.max(0, ctx - params.newCtxTokens) * rho({ model, ttlMin, params, keepWarmOn })
69}
70
71export function breakEvenTurns(args) {
72 const saving = savingPerTurnUsd(args)
73 if (saving <= 0) return Infinity
74 return compactionCostUsd(args).totalUsd / saving
75}
76
77// The context size above which compacting (cache warm) pays back within `horizon`
78// messages: C·(H·rho - r) = N·H·rho + s·o + N·share·w
79export function breakEvenCtx({ model, ttlMin, params, horizon = params.horizonTurns, keepWarmOn = false }) {
80 const { r, w, o } = prices(model, ttlMin)
81 const rh = rho({ model, ttlMin, params, keepWarmOn }) * horizon
82 const den = rh - r
83 if (den <= 0) return Infinity
84 return (params.newCtxTokens * rh + params.summaryTokens * o + params.newCtxTokens * params.rewriteShare * w) / den
85}
86
87// Compacting just before the cache expires, against letting it expire and
88// rewriting on return: better if you come back with probability above this.
89// Above 1 means never; Infinity means the context is no bigger than the compacted one.
90export function idleReturnThreshold({ ctx, model, ttlMin, params }) {
91 const { r, w, o } = prices(model, ttlMin)
92 const gain = ctx * w - params.newCtxTokens * params.rewriteShare * w
93 if (ctx <= params.newCtxTokens || gain <= 0) return Infinity
94 return (ctx * r + params.summaryTokens * o) / gain
95}
96
97export function advise(args) {
98 const { ctx, params } = args
99 const costUsd = compactionCostUsd(args).totalUsd
100 const savingUsd = savingPerTurnUsd(args)
101 const turns = breakEvenTurns(args)
102 const out = {
103 costUsd,
104 savingPerTurnUsd: savingUsd,
105 breakEvenTurns: turns,
106 breakEvenCtx: breakEvenCtx(args),
107 idleReturnThreshold: idleReturnThreshold(args),
108 }
109 if (ctx < params.newCtxTokens * TOO_SMALL_FACTOR) return { ...out, verdict: 'too-small' }
110 return { ...out, verdict: turns <= params.horizonTurns ? 'compact' : 'wait' }
111}
112
113function median(values) {
114 const v = values.filter((x) => Number.isFinite(x)).sort((a, b) => a - b)
115 if (!v.length) return null
116 const mid = Math.floor(v.length / 2)
117 return v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2
118}
119
120// Replace each default with the median of what the mod measured, once it has
121// MEASURED_MIN figures for it; say which are which.
122export function calibrate(events, defaults = DEFAULT_PARAMS) {
123 const params = { ...defaults }
124 const sources = { newCtxTokens: 'default', summaryTokens: 'default', rewriteShare: 'default' }
125 const compacts = events.filter((e) => e.kind === 'compact')
126 const rebuilds = events.filter((e) => e.kind === 'compact-rebuild' && e.total > 0)
127 // N is what the next request really carries: the first request after compacting
128 const after = rebuilds.map((e) => e.total)
129 const wrote = compacts.map((e) => e.outputTokens).filter((x) => x > 0)
130 const shares = rebuilds.map((e) => e.written / e.total)
131 if (after.length >= MEASURED_MIN) {
132 params.newCtxTokens = median(after)
133 sources.newCtxTokens = 'measured'
134 }
135 if (wrote.length >= MEASURED_MIN) {
136 params.summaryTokens = median(wrote)
137 sources.summaryTokens = 'measured'
138 }
139 if (shares.length >= MEASURED_MIN) {
140 params.rewriteShare = median(shares)
141 sources.rewriteShare = 'measured'
142 }
143 return { params, sources }
144}
145
146// Share of messages that arrive after a gap longer than the TTL, shrunk toward
147// the prior so a short history can't swing it.
148export function gapShare({ turns, longGaps }, prior = DEFAULT_PARAMS.gapShare) {
149 const value = (longGaps + GAP_PRIOR_WEIGHT * prior) / (turns + GAP_PRIOR_WEIGHT)
150 return { value, source: turns >= GAP_MEASURED_MIN ? 'measured' : 'default' }
151}
152
153// Model requests per message, shrunk toward the prior like gapShare
154export function stepsPerTurn({ turns, steps }, prior = DEFAULT_PARAMS.stepsPerTurn) {
155 return (steps + STEPS_PRIOR_WEIGHT * prior) / (turns + STEPS_PRIOR_WEIGHT)
156}
157
158// The horizon is the messages still to come once compacting is worth a look. Each
159// 'advice' event is one notice's outcome: `remaining` messages from the notice to
160// the end of that session, so a compaction that carried on afterwards doesn't cut
161// it short. Median once 3 are known, else the prior.
162export function horizonFrom(events, prior = DEFAULT_PARAMS.horizonTurns) {
163 const left = events.filter((e) => e.kind === 'advice' && Number.isFinite(e.remaining)).map((e) => e.remaining)
164 if (left.length < MEASURED_MIN) return { value: prior, source: 'default' }
165 return { value: Math.max(1, Math.round(median(left))), source: 'measured' }
166}
167
168// What came of the notices: how many, how often they led to a compaction, how
169// many messages later, how many messages were left.
170export function adviceSummary(events, since = 0) {
171 const adv = events.filter((e) => e.kind === 'advice' && (e.at === undefined || e.at >= since))
172 const acted = adv.filter((e) => e.compacted)
173 return {
174 shown: adv.length,
175 acted: acted.length,
176 actedShare: adv.length ? acted.length / adv.length : 0,
177 medianRemaining: median(adv.map((e) => e.remaining)),
178 medianMessagesToAct: median(acted.map((e) => e.compactedAfter)),
179 }
180}
181