A prompt-cache line under your status line, from Claude Code's own cache figures: time left while warm, hit rate and misses, and how many tokens the next…

A private reference collection of public Claude Code mods, with a security-oriented analysis.
mods/Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.
hooks/register.ts 231 lines1// Cache Clock: a prompt-cache line under the status line, drawn from the
2// status line input's `prompt_cache`. Plugins cannot register a status line,
3// so /cache-clock setup points statusLine at a script that runs the person's
4// own status line first and adds the cache line under it.
5import type { EngineInterface, Register } from 'claude-code'
6
7// The scripts setup copies out of the plugin. They live in the config dir, not
8// in the plugin's folder, which changes with every version of the mod.
9const SCRIPTS = ['cache-clock.mjs', 'render.mjs']
10// What marks a statusLine command as this mod's.
11const MARK = 'cache-clock/cache-clock.mjs'
12const REFRESH_SECONDS = 30
13// The first Claude Code that sends `prompt_cache` to the status line.
14const MIN_VERSION = [2, 1, 251]
15
16type StatusLine = { type?: string; command?: string; refreshInterval?: number; [key: string]: unknown }
17// What setup leaves for remove and for the script: the settings file it wrote,
18// the statusLine that file had before, and the command the script runs first.
19// One per settings file (setup-<key>.json), so setups in several projects and
20// in the user settings each keep their own original.
21type Saved = { file: string; previous: StatusLine | null; wrapped: string | null }
22
23export const register: Register = on => {
24 on('session.start', async ($, e, next) => {
25 const r = await next(e)
26 await $.command.register({
27 name: 'cache-clock',
28 description: 'Add a prompt-cache line under the status line; /cache-clock remove takes it out',
29 argumentHint: '[setup|remove]',
30 }).catch(() => {}) // a name Claude Code already has is refused: start anyway
31 void refreshScripts($).catch(() => {})
32 return r
33 })
34
35 on('command.run', { command: 'cache-clock' }, async ($, e) => {
36 const word = e.args.trim().toLowerCase()
37 try {
38 if (word === 'setup') return { text: await setup($) }
39 if (word === 'remove') return { text: await remove($) }
40 if (word === '') return { text: await status($) }
41 return { text: 'Usage: /cache-clock [setup|remove]' }
42 } catch (err) {
43 return { text: `cache-clock: ${err instanceof Error ? err.message : String(err)}` }
44 }
45 })
46}
47
48async function setup($: EngineInterface) {
49 const dir = await clockDir($)
50 const found = await inForce($)
51 if (found.source === 'policy' || found.source === 'flag') {
52 const where = found.source === 'policy' ? 'managed settings' : '--settings'
53 return `Your status line comes from ${where}, which cache-clock can't change. Nothing changed.`
54 }
55 if (isOurs(found.statusLine)) {
56 await copyScripts($, dir)
57 return 'cache-clock is already set up. Its scripts are up to date.'
58 }
59
60 const file = await targetFile($, found.source)
61 const settings = await readJson($, file)
62 const current = found.statusLine
63 const wrapped = typeof current?.command === 'string' && current.command.trim() ? current.command : null
64 const saved: Saved = { file, previous: asStatusLine(settings.statusLine), wrapped }
65 const key = keyFor(file)
66
67 await copyScripts($, dir)
68 await $.fs.write(`${dir}/setup-${key}.json`, JSON.stringify(saved, null, 2) + '\n')
69
70 const { command: _command, refreshInterval, ...kept } = current ?? {}
71 settings.statusLine = {
72 ...kept,
73 type: 'command',
74 command: `node "${shellQuoted(`${dir}/cache-clock.mjs`)}" ${key}`,
75 refreshInterval: typeof refreshInterval === 'number' && refreshInterval < REFRESH_SECONDS ? refreshInterval : REFRESH_SECONDS,
76 }
77 await $.fs.write(file, JSON.stringify(settings, null, 2) + '\n')
78
79 const lines = [
80 wrapped
81 ? `cache-clock set up in ${file}. Your status line stays as it is, with the cache line under it.`
82 : `cache-clock set up in ${file}. The cache line is your status line.`,
83 'It shows up after Claude\'s next reply. /cache-clock remove puts things back.',
84 ...(await warnings($)),
85 ]
86 return lines.join('\n')
87}
88
89// Takes out the setup in force here: the user settings', or this project's local one.
90async function remove($: EngineInterface) {
91 const dir = await clockDir($)
92 const found = await inForce($)
93 const file = await targetFile($, isOurs(found.statusLine) ? found.source : 'user')
94 const key = keyFor(file)
95 const saved = await readSaved($, `${dir}/setup-${key}.json`)
96 if (!saved) return 'cache-clock is not set up here. Nothing changed.'
97
98 const settings = await readJson($, saved.file)
99 if (!isOurs(asStatusLine(settings.statusLine))) {
100 await $.fs.write(`${dir}/setup-${key}.json`, '{}\n')
101 return `The status line in ${saved.file} was changed since setup, so it was left as it is.`
102 }
103 if (saved.previous) settings.statusLine = saved.previous
104 else delete settings.statusLine
105 await $.fs.write(saved.file, JSON.stringify(settings, null, 2) + '\n')
106 await $.fs.write(`${dir}/setup-${key}.json`, '{}\n')
107 return saved.previous
108 ? `cache-clock removed. Your status line in ${saved.file} is back as it was.`
109 : `cache-clock removed from ${saved.file}.`
110}
111
112async function status($: EngineInterface) {
113 const found = await inForce($)
114 if (isOurs(found.statusLine)) {
115 return ['cache-clock is on. /cache-clock remove takes it out.', ...(await warnings($))].join('\n')
116 }
117 return 'cache-clock is off. /cache-clock setup adds the cache line under your status line.'
118}
119
120// After an update of the mod, the copies setup made catch up with the new scripts.
121async function refreshScripts($: EngineInterface) {
122 if (!isOurs((await inForce($)).statusLine)) return
123 const dir = await clockDir($)
124 for (const name of SCRIPTS) {
125 const fresh = await $.fs.read(`${$.plugin.root}/statusline/${name}`)
126 const copy = await $.fs.read(`${dir}/${name}`).catch(() => null)
127 if (copy !== fresh) await $.fs.write(`${dir}/${name}`, fresh)
128 }
129}
130
131async function copyScripts($: EngineInterface, dir: string) {
132 for (const name of SCRIPTS) {
133 await $.fs.write(`${dir}/${name}`, await $.fs.read(`${$.plugin.root}/statusline/${name}`))
134 }
135}
136
137async function warnings($: EngineInterface) {
138 const out: string[] = []
139 const node = await $.process.run(['node', '--version'], { timeoutMs: 5000 }).catch(() => null)
140 if (!node || node.exitCode !== 0) out.push('Warning: node is not on your PATH. The cache line needs Node to run.')
141 const { base, version } = await $.session.version()
142 if (isOlder(base ?? version, MIN_VERSION)) {
143 out.push(`Warning: Claude Code ${version} is older than 2.1.251, so it sends no cache data. The line stays empty until you update.`)
144 }
145 return out
146}
147
148// The statusLine in force and the settings source it comes from, highest precedence first.
149async function inForce($: EngineInterface) {
150 for (const source of ['policy', 'flag', 'local', 'project', 'user'] as const) {
151 const settings = await $.settings.read({ source }).catch(() => ({}))
152 const statusLine = asStatusLine((settings as Record<string, unknown>).statusLine)
153 if (statusLine) return { source, statusLine }
154 }
155 return { source: null, statusLine: null }
156}
157
158// The file setup writes: where the status line lives, except that a project's
159// shared settings get a local override instead of a path from this machine.
160async function targetFile($: EngineInterface, source: string | null) {
161 const root = await $.session.root() // where Claude Code reads a project's settings from
162 if (source === 'local' || source === 'project') return `${root}/.claude/settings.local.json`
163 return `${await configDir($)}/settings.json`
164}
165
166async function configDir($: EngineInterface) {
167 const custom = await $.env.get('CLAUDE_CONFIG_DIR')
168 if (custom) return custom.replace(/\/+$/, '')
169 const home = await $.env.get('HOME')
170 if (!home) throw new Error('HOME is not set, so the Claude config folder is unknown.')
171 return `${home}/.claude`
172}
173
174async function clockDir($: EngineInterface) {
175 return `${await configDir($)}/cache-clock`
176}
177
178async function readJson($: EngineInterface, file: string): Promise<Record<string, unknown>> {
179 if (!(await $.fs.exists(file))) return {}
180 const text = await $.fs.read(file)
181 if (!text.trim()) return {}
182 let parsed: unknown
183 try {
184 parsed = JSON.parse(text)
185 } catch {
186 throw new Error(`${file} is not valid JSON, so it was left as it is.`)
187 }
188 if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${file} does not hold a settings object.`)
189 return parsed as Record<string, unknown>
190}
191
192async function readSaved($: EngineInterface, path: string): Promise<Saved | null> {
193 const text = await $.fs.read(path).catch(() => null)
194 if (!text) return null
195 try {
196 const saved = JSON.parse(text)
197 return typeof saved?.file === 'string' ? (saved as Saved) : null
198 } catch {
199 return null
200 }
201}
202
203// A short stable name for a settings file's saved state: FNV-1a of its path, in hex.
204function keyFor(file: string) {
205 let hash = 0x811c9dc5
206 for (let i = 0; i < file.length; i++) hash = Math.imul(hash ^ file.charCodeAt(i), 0x01000193) >>> 0
207 return hash.toString(16).padStart(8, '0')
208}
209
210// Safe inside double quotes for sh.
211function shellQuoted(path: string) {
212 return path.replace(/["\\$`]/g, c => `\\${c}`)
213}
214
215function asStatusLine(value: unknown): StatusLine | null {
216 return value && typeof value === 'object' && !Array.isArray(value) ? (value as StatusLine) : null
217}
218
219function isOurs(statusLine: StatusLine | null) {
220 return typeof statusLine?.command === 'string' && statusLine.command.includes(MARK)
221}
222
223function isOlder(version: string, min: number[]) {
224 const parts = version.split(/[.-]/).slice(0, 3).map(Number)
225 if (parts.some(Number.isNaN)) return false
226 for (let i = 0; i < min.length; i++) {
227 if (parts[i] !== min[i]) return parts[i] < min[i]
228 }
229 return false
230}
231