Prompt cache status band (c / ctx / rwc / sc / plan limits) with hover help, cold-send guard, keep-warm, /board, and /handoff that also picks up handoff files…

A compact prompt-cache status band for Claude Code, with a cold-send guard, keep-warm, a board of all local chats, and handoffs to a fresh chat.
This is a fork of Cache Keeper by Nate Herk (nateherkai/claude-code-mods), MIT licensed. The cache logic, board, guard and handoff flow are his work. See Changes from Cache Keeper for what this fork adds.
<img src="screenshots/band.png" alt="cache-status band above the prompt" width="640">
The band above the prompt: cache warm for 60 minutes, 342k tokens in context, a cold rewrite would cost about $2.73, the session cost $13 so far, and the 5-hour and weekly plan limits.
Every request re-reads the whole chat. From the prompt cache that costs a tenth of the normal input price. Once the cache expires (5 minutes or 1 hour idle, depending on your plan), the next message writes the whole chat into the cache again at up to 2x the input price. On a large chat that one message can cost a few dollars.
claude plugin test in any folder answers "no hooks module to load" when mods can load./plugin marketplace add IT-BAER/claude-forge
/plugin install cache-status@claude-forge
Start a new session afterwards.
● c 60m │ ctx 342k │ rwc ≈ $2.73 │ sc $13 │ 5h 84% · w 24%
| Part | Meaning |
|---|---|
c 60m | Prompt cache state and minutes until it expires. Yellow 5 minutes before expiry, red once cold. |
ctx | Tokens in context. |
rwc | Rewrite cost: what writing the context into the cache again would cost if it expires. |
sc | Session cost so far, at API list prices. |
5h, w | 5-hour and weekly plan limits used. Yellow at 80 %, red at 95 %. |
Hover c, rwc or sc for a one-line explanation above the band. On a subscription the prices show up as usage against your limits, not as a bill.
/keepwarm [hours|off] keeps this chat's cache warm, 4 hours by default, with a small ping shortly before it would expire./board shows every local chat in one pane: waiting on you first, then working, then the ones whose cache cools soonest./handoff (or the handoff button) runs the bundled session-handoff skill, then clears the chat and starts the fresh one from the handoff./cache shows status and settings: ttl 5|60|auto, guard on|off, big 150k, alerts [on|off]./cache alerts toggles them.c, rwc, sc, w) so the band fits next to the prompt, session cost before the plan limits.session-handoff.md, a restart-packet.md (also with a suffix, like session-handoff-pve.md) or a file under handoffs/, the band offers to clear and continue from that file. Any header format works; only a header marked status: consumed (or another non-active status) is skipped. If a handoff turn ends on a short answer (a Stop hook added a tail), the last handoff file written in the session is used instead. The fresh chat gets a short "read this file" prompt instead of the whole text.cache-status. Data lives in ~/.claude/mods-data/cache-status/.Everything stays on your machine, under ~/.claude/mods-data/. The mod makes no network calls of its own. Keep-warm pings are small model requests, sent only while /keepwarm is on.
MIT. See LICENSE. Original work copyright Nate Herk.
hooks/register.mjs 944 lines1// Cache Status (fork of Cache Keeper by Nate Herk): 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'
14
15const MIN = 60000
16const HEARTBEAT_EVERY = 30000
17const PANE = 'cache-board'
18
19// This session
20const S = {
21 id: '',
22 cwd: '',
23 label: '',
24 model: '',
25 lastActivity: 0, // last main-loop request or keep-warm ping that touched the cache
26 ctx: 0,
27 window: 0,
28 costUsd: 0,
29 ttlMin: 60,
30 ttlSource: 'default',
31 coldRestarts: [],
32 working: false,
33 turnId: '', // the main loop's running (or last) turn
34 waitingSince: 0,
35 waitingFor: '',
36 tool: '',
37 lastTurnEnd: 0,
38 keepWarm: false,
39 keepWarmUntil: 0,
40 pings: 0,
41 pingUsd: 0,
42 lastPingAt: 0,
43 warnedFor: 0,
44 agentsRunning: 0,
45 rateLimits: [],
46}
47
48const settings = { bigTokens: 150000, guard: true, ttlMin: 0, alerts: false, keepWarmHours: 4 }
49const names = { cache: 'cache', keepwarm: 'keepwarm', board: 'board', handoff: 'handoff' }
50
51let now = 0
52let home = ''
53let lastHeartbeat = 0
54let rec = { on: false, strict: false }
55let mask = (s) => s
56let fleet = [] // heartbeats of every session, this one included
57const alerted = new Map() // sessionId -> state or warning key already announced
58let paneOpen = false
59let justCompacted = false
60let handoffPending = false
61
62// The handoff flow. When /session-handoff runs (the band's button, /handoff, a
63// typed command, or Claude calling the skill), the answer of the turn it starts
64// is captured and saved to a file. "clear and continue" then runs /clear and
65// sends the handoff as the fresh chat's first prompt.
66const HANDOFF_SKILL = /(^|:)session-handoff$/
67const H = {
68 armed: false,
69 notTurn: '', // a turn already running when the handoff was queued: not the handoff's
70 askAfter: false, // /handoff asks "clear and continue?" once the handoff is in
71 text: '',
72 path: '',
73 file: '', // a handoff file Claude wrote with Write/Edit this turn (no button, no command)
74 lastFile: '', // the last handoff file written this session: the fallback when a handoff turn ends on a short answer
75 fromFile: false, // H.text came from that file: the fresh chat reads it by path
76 continuing: false,
77}
78
79// session-handoff.md, restart-packet.md, suffixed names (session-handoff-pve.md), or any .md under /handoffs/
80const HANDOFF_FILE = /[^"'\s]*(?:(?:session-handoff|restart-packet)[^"'\s/]*\.md|\/handoffs\/[^"'\s]*\.md)/
81
82// Notes a write to a handoff file; the turn end loads it.
83function noteHandoffFile(e) {
84 if (e.tool !== 'Write' && e.tool !== 'Edit') return
85 let raw = ''
86 try {
87 raw = JSON.stringify(e.input || e.args || e.params || e)
88 } catch {
89 return
90 }
91 const m = HANDOFF_FILE.exec(raw.replace(/\\\\/g, '/'))
92 if (m) H.file = H.lastFile = m[0]
93}
94
95// A handoff file becomes the ready handoff unless its header retires it
96// (status: consumed). Free-hand handoffs often lack the skill's <!-- handoff: --> header.
97async function captureHandoffFile($, path = H.file) {
98 H.file = ''
99 try {
100 if (!path || !(await $.fs.exists(path))) return false
101 const text = (await $.fs.read(path)).replace(/^/, '')
102 const status = /status:\s*(\w+)/i.exec(text.split('\n', 3).join('\n'))
103 if (text.trim().length < 200 || (status && status[1].toLowerCase() !== 'active')) return false
104 const askAfter = H.askAfter
105 H.text = text.trim()
106 H.path = path
107 H.fromFile = true
108 H.armed = false
109 H.notTurn = ''
110 H.askAfter = false
111 $.ui.toast(`Handoff file written: ${path}. Press c on the band or type /${names.handoff} continue to clear and continue.`, { timeoutMs: 15000 })
112 // Off the hook: the turn is ending, and a dialog would hold it open
113 if (askAfter) $.clock.after(300, () => offerContinue($).catch(() => {}))
114 return true
115 } catch {
116 return false
117 }
118}
119
120function armHandoff(insideTurn) {
121 H.armed = true
122 H.notTurn = insideTurn ? '' : S.working ? S.turnId : ''
123}
124
125// The handoff without any preamble Claude put before its heading
126function handoffBody(answer) {
127 const text = String(answer || '').trim()
128 const i = text.indexOf('# Session Handoff')
129 return i > 0 ? text.slice(i) : text
130}
131
132function fileStamp(ms) {
133 const d = new Date(ms)
134 const two = (n) => String(n).padStart(2, '0')
135 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}`
136}
137
138async function saveHandoff($, text) {
139 const project = basename(S.cwd).replace(/[^A-Za-z0-9-]+/g, '-') || 'session'
140 const path = `${home || '.'}/.claude/mods-data/cache-status/handoffs/${fileStamp(now)}-${project}-${S.id.slice(0, 8)}.md`.replace(/\\/g, '/')
141 await $.fs.write(path, text + '\n')
142 return path
143}
144
145async function captureHandoff($, e) {
146 H.armed = false
147 H.notTurn = ''
148 const askAfter = H.askAfter
149 H.askAfter = false
150 const body = e.reason === 'answer' ? handoffBody(e.answer) : ''
151 if (body.length < 200) {
152 // A Stop hook can end the turn on a short tail; the skill's file still holds the handoff
153 H.askAfter = askAfter
154 if (await captureHandoffFile($, H.lastFile)) return
155 H.askAfter = false
156 $.ui.toast('The handoff turn ended without a handoff, so nothing was saved.', { timeoutMs: 8000 })
157 return
158 }
159 H.text = body
160 try {
161 H.path = await saveHandoff($, body)
162 } catch {
163 H.path = ''
164 }
165 const saved = H.path ? `Handoff saved to ${H.path}.` : 'Handoff captured (the backup file could not be written).'
166 $.ui.toast(`${saved} Press c on the band or type /${names.handoff} continue to clear and continue.`, { timeoutMs: 15000 })
167 // Off the hook: the turn is ending, and a dialog would hold it open
168 if (askAfter) $.clock.after(300, () => offerContinue($).catch(() => {}))
169}
170
171async function offerContinue($) {
172 if (!H.text) return
173 let answer = ''
174 try {
175 answer = await $.ui.ask('Handoff saved. Clear this chat and continue with it in a fresh context?', ['Clear and continue', 'Keep this chat'])
176 } catch {
177 return // dismissed: the band button and /handoff continue still work
178 }
179 if (answer === 'Clear and continue') await clearAndContinue($)
180}
181
182function continuationPrompt() {
183 if (H.fromFile) return `Continue from session handoff: read ${H.path}, consume it with session-handoff, do not re-explore.`
184 const where = H.path ? ` (saved at ${H.path})` : ''
185 return `Handoff from my previous session${where}:\n\n${H.text}`
186}
187
188async function clearAndContinue($) {
189 if (H.continuing) return
190 if (!H.text) {
191 $.ui.toast(`No handoff ready. Press h on the band or type /${names.handoff} first.`, { timeoutMs: 8000 })
192 return
193 }
194 if (S.working) {
195 $.ui.toast('Claude is still working. Clear and continue once this turn ends.', { timeoutMs: 8000 })
196 return
197 }
198 const text = continuationPrompt()
199 const path = H.path
200 H.continuing = true
201 $.ui.invalidate('ui.render')
202 try {
203 await $.command.run({ command: 'clear', args: '' })
204 } catch (err) {
205 H.continuing = false
206 $.ui.invalidate('ui.render')
207 $.ui.toast('Could not run /clear: ' + clip(String((err && err.message) || err), 100) + (path ? ` The handoff is saved at ${path}.` : ''), { timeoutMs: 10000 })
208 return
209 }
210 H.text = ''
211 H.path = ''
212 H.fromFile = false
213 try {
214 await $.prompt.submit({ text, asUser: true })
215 } catch {
216 // Not sent: leave it in the prompt box for one Enter
217 let filled = false
218 try {
219 filled = !!(await $.prompt.fill({ text })).isFilled
220 } catch {
221 filled = false
222 }
223 $.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 })
224 } finally {
225 H.continuing = false
226 $.ui.invalidate('ui.render')
227 }
228}
229
230// Runs the /session-handoff skill as if you typed it. The engine queues it
231// until Claude finishes the current turn.
232async function runHandoff($) {
233 if (handoffPending) {
234 $.ui.toast('Session handoff is already queued.')
235 return
236 }
237 try {
238 const commands = await $.command.list()
239 const cmd = commands.find((c) => c.name === 'session-handoff') || commands.find((c) => HANDOFF_SKILL.test(c.name))
240 if (!cmd) {
241 H.askAfter = false
242 $.ui.toast('No /session-handoff skill in this session.', { timeoutMs: 8000 })
243 return
244 }
245 handoffPending = true
246 armHandoff(false)
247 $.ui.invalidate('ui.render')
248 $.ui.toast(S.working ? 'Session handoff queued: it runs when Claude finishes this turn.' : 'Running /session-handoff.')
249 await $.command.run({ command: cmd.name, args: '' })
250 } catch (err) {
251 H.armed = false
252 H.askAfter = false
253 $.ui.toast('Could not start /session-handoff: ' + clip(String((err && err.message) || err), 100), { timeoutMs: 8000 })
254 } finally {
255 handoffPending = false
256 $.ui.invalidate('ui.render')
257 }
258}
259
260function ttlMin() {
261 return settings.ttlMin || S.ttlMin
262}
263
264// Plan limit windows as the API reports them: five_hour, seven_day, spend_limit
265const LIMIT_LABEL = { five_hour: '5h', seven_day: 'w', spend_limit: 'spend' }
266
267function limitsText(limits) {
268 return limits.map((l) => `${LIMIT_LABEL[l.kind] || l.kind} ${Math.round(l.percentUsed)}%`).join(' · ')
269}
270
271function limitTone(limits, extra) {
272 const top = Math.max(...limits.map((l) => l.percentUsed || 0))
273 if (top >= 95) return { ...extra, color: 'red', bold: true }
274 if (top >= 80) return { ...extra, color: 'yellow' }
275 return { ...extra, dimColor: true }
276}
277
278function money(v) {
279 return rec.strict ? '$•••' : usd(v)
280}
281
282function msLeft(lastActivity, ttl) {
283 if (!lastActivity) return null
284 return ttl * MIN - (now - lastActivity)
285}
286
287function cacheState(hb) {
288 const left = msLeft(hb.lastActivity, hb.ttlMin)
289 if (left === null) return { kind: 'unknown', left: 0 }
290 if (hb.keepWarm) return { kind: 'kept', left }
291 if (left <= 0) return { kind: 'cold', left }
292 if (left <= 5 * MIN) return { kind: 'cooling', left }
293 return { kind: 'warm', left }
294}
295
296function myHeartbeat() {
297 const coldUsd = S.coldRestarts.reduce((a, c) => a + c.usd, 0)
298 let state = 'idle'
299 if (S.waitingSince && now - S.waitingSince > 5000) state = 'waiting'
300 else if (S.working) state = 'working'
301 return {
302 id: S.id,
303 label: S.label,
304 cwd: S.cwd,
305 model: S.model,
306 state,
307 waitingFor: S.waitingFor,
308 tool: S.tool,
309 ctx: S.ctx,
310 window: S.window,
311 ttlMin: ttlMin(),
312 lastActivity: S.lastActivity,
313 keepWarm: S.keepWarm,
314 keepWarmUntil: S.keepWarmUntil,
315 pings: S.pings,
316 pingUsd: S.pingUsd,
317 costUsd: S.costUsd,
318 coldCount: S.coldRestarts.length,
319 coldUsd,
320 agentsRunning: S.agentsRunning,
321 lastTurnEnd: S.lastTurnEnd,
322 updatedAt: now,
323 }
324}
325
326async function registerCommand($, name, description, argumentHint, immediate) {
327 const spec = immediate ? { name, description, argumentHint, immediate: true } : { name, description, argumentHint }
328 try {
329 await $.command.register(spec)
330 return name
331 } catch {
332 try {
333 await $.command.register({ ...spec, name: 'ck-' + name })
334 return 'ck-' + name
335 } catch {
336 return null
337 }
338 }
339}
340
341async function readRecording($) {
342 try {
343 const path = (home || '.') + '/.claude/mods-data/recording.json'
344 if (!(await $.fs.exists(path))) {
345 rec = { on: false, strict: false }
346 } else {
347 const flag = JSON.parse(await $.fs.read(path))
348 rec = { on: !!flag.on, strict: !!flag.on && !!flag.strict }
349 }
350 } catch {
351 rec = { on: false, strict: false }
352 }
353 mask = rec.on ? makeMasker({ strict: rec.strict }) : (s) => s
354}
355
356async function heartbeat($, force) {
357 if (!S.id) return
358 if (!force && now - lastHeartbeat < 5000) return
359 lastHeartbeat = now
360 try {
361 const agents = await $.agent.list()
362 S.agentsRunning = agents.filter((a) => a.status === 'running').length
363 } catch {
364 // agent list unavailable: keep the last count
365 }
366 await $.store.set('hb:' + S.id, myHeartbeat())
367}
368
369async function readFleet($) {
370 const keys = (await $.store.keys()).filter((k) => k.startsWith('hb:'))
371 const list = []
372 for (const k of keys) {
373 const hb = await $.store.get(k)
374 if (!hb || typeof hb !== 'object') continue
375 if (hb.id === S.id) continue
376 // a session that stopped heartbeating an hour ago has ended or crashed
377 if (now - (hb.updatedAt || 0) > 60 * MIN) {
378 await $.store.delete(k)
379 continue
380 }
381 list.push({ ...hb, stale: now - (hb.updatedAt || 0) > 3 * MIN })
382 }
383 list.push({ ...myHeartbeat(), self: true })
384 fleet = list
385}
386
387// Commands another session's board sent this one, through the shared store
388async function applyRemoteCommands($) {
389 const c = await $.store.get('cmd:' + S.id)
390 if (!c) return
391 await $.store.delete('cmd:' + S.id)
392 if (c.keepWarm === true) startKeepWarm($, settings.keepWarmHours)
393 if (c.keepWarm === false) stopKeepWarm($, 'stopped from the board')
394}
395
396function startKeepWarm($, hours) {
397 S.keepWarm = true
398 S.keepWarmUntil = now + hours * 60 * MIN
399 $.ui.toast(`Keeping this cache warm until ${clock(S.keepWarmUntil)}. A ping reads the cache before it expires.`)
400}
401
402function stopKeepWarm($, why) {
403 if (!S.keepWarm) return
404 S.keepWarm = false
405 $.ui.toast(`Keep warm off (${why}). ${S.pings} ping(s), ${money(S.pingUsd)}.`, { timeoutMs: 8000 })
406}
407
408async function keepWarmStep($) {
409 if (!S.keepWarm) return
410 if (now >= S.keepWarmUntil) return stopKeepWarm($, 'time limit reached')
411 if (S.working || !S.lastActivity) return
412 const ttl = ttlMin()
413 const margin = ttl >= 60 ? 8 * MIN : 90000
414 if (now - S.lastActivity < ttl * MIN - margin) return
415 let reply = null
416 try {
417 reply = await $.model.fork({ prompt: 'cache-status keep-alive ping. Reply with only: ok' })
418 } catch {
419 reply = null
420 }
421 if (!reply || !reply.usage) {
422 return stopKeepWarm($, 'the ping got no answer, so the cache may already be cold')
423 }
424 const u = reply.usage
425 S.pings += 1
426 S.lastPingAt = now
427 S.pingUsd += requestCost(u, S.model, ttl)
428 // A ping that writes instead of reading did not hit the cache: stop paying for it
429 if ((u.cache_creation_input_tokens || 0) > 0.1 * Math.max(1, u.cache_read_input_tokens || 0)) {
430 return stopKeepWarm($, `the ping wrote ${tokens(u.cache_creation_input_tokens)} tokens instead of reading the cache`)
431 }
432 S.lastActivity = now
433}
434
435function warnStep($) {
436 if (!settings.alerts) return
437 for (const hb of fleet) {
438 if (hb.stale || hb.keepWarm || (hb.ctx || 0) < settings.bigTokens) continue
439 const st = cacheState(hb)
440 if (st.kind !== 'cooling') continue
441 const key = 'cool:' + hb.lastActivity
442 if (alerted.get(hb.id) === key) continue
443 alerted.set(hb.id, key)
444 const cost = money(rewriteCost(hb.ctx, hb.model, hb.ttlMin))
445 const who = hb.self ? 'This session' : `"${mask(hb.label)}"`
446 const how = hb.self ? `type /${names.keepwarm} or press 1` : `open /${names.board} to keep it warm`
447 $.ui.toast(`${who}: ${tokens(hb.ctx)}-token cache goes cold in ${minutes(st.left)}. Rewriting it costs about ${cost}. ${how}.`, { timeoutMs: 15000 })
448 }
449 // Another session started waiting on a permission or a question
450 for (const hb of fleet) {
451 if (hb.self || hb.stale) continue
452 const prev = alerted.get('state:' + hb.id)
453 alerted.set('state:' + hb.id, hb.state)
454 if (hb.state === 'waiting' && prev && prev !== 'waiting') {
455 $.ui.toast(`"${mask(hb.label)}" is waiting on you${hb.waitingFor ? ': ' + hb.waitingFor : ''}`, { timeoutMs: 10000 })
456 }
457 }
458}
459
460async function tick($) {
461 now = await $.clock.now()
462 await readRecording($)
463 await applyRemoteCommands($)
464 await keepWarmStep($)
465 await heartbeat($, true)
466 await readFleet($)
467 warnStep($)
468 $.ui.invalidate('ui.render')
469}
470
471async function loadSettings($) {
472 const saved = await $.store.get('settings')
473 if (saved && typeof saved === 'object') Object.assign(settings, saved)
474}
475
476function statusText() {
477 const st = cacheState(myHeartbeat())
478 const ttl = ttlMin()
479 const lines = []
480 lines.push(`Cache Status: ${tokens(S.ctx)} tokens in context on ${priceFor(S.model).id}, cache window ${ttl} min (${settings.ttlMin ? 'set by you' : S.ttlSource}).`)
481 if (st.kind === 'unknown') lines.push('No request yet this session, so the cache state is unknown.')
482 if (st.kind === 'warm' || st.kind === 'cooling') lines.push(`Cache is warm for about ${minutes(st.left)} more.`)
483 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))}.`)
484 if (S.keepWarm) lines.push(`Keep warm is on until ${clock(S.keepWarmUntil)}: ${S.pings} ping(s), ${money(S.pingUsd)} so far.`)
485 if (S.rateLimits.length) lines.push(`Plan limits used: ${limitsText(S.rateLimits)}.`)
486 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))}).`)
487 lines.push(`Cold-send guard: ${settings.guard ? 'on' : 'off'} for contexts over ${tokens(settings.bigTokens)} tokens. Alerts: ${settings.alerts ? 'on' : 'off'}.`)
488 lines.push(`Settings: /${names.cache} ttl 5|60|auto, /${names.cache} guard on|off, /${names.cache} big 150k, /${names.cache} alerts [on|off] (no value toggles). Board: /${names.board}.`)
489 return lines.join('\n')
490}
491
492function parseTokens(text) {
493 const m = String(text || '').trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([km]?)$/)
494 if (!m) return null
495 return Math.round(Number(m[1]) * (m[2] === 'm' ? 1e6 : m[2] === 'k' ? 1e3 : 1))
496}
497
498export function register(on) {
499 on('session.start', async ($, e, next) => {
500 now = await $.clock.now()
501 home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
502 S.id = await $.session.id()
503 S.cwd = await $.session.cwd()
504 S.label = basename(S.cwd)
505 S.model = await $.session.model()
506 await loadSettings($)
507 await readRecording($)
508 names.cache = (await registerCommand($, 'cache', 'Cache Status status and settings', '[ttl 5|60|auto] [guard on|off] [big 150k] [alerts [on|off]]')) || names.cache
509 names.keepwarm = (await registerCommand($, 'keepwarm', 'Keep this session\'s prompt cache warm (default 4 hours), or /keepwarm off', '[hours|off]', true)) || names.keepwarm
510 names.board = (await registerCommand($, 'board', 'Every local Claude Code session: state, context, cache, cost', '', true)) || names.board
511 names.handoff = (await registerCommand($, 'handoff', 'Session handoff, then clear this chat and continue with it (/handoff continue)', '[continue]')) || names.handoff
512 $.clock.every(HEARTBEAT_EVERY, () => tick($).catch(() => {}))
513 await heartbeat($, true)
514 return next(e)
515 })
516
517 // /clear, /resume and /branch start from an unknown cache. The process goes
518 // on under a new session id, and no session.start fires for it.
519 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
520 S.lastActivity = 0
521 S.keepWarm = false
522 H.armed = false
523 try {
524 S.id = (await $.session.id()) || S.id
525 } catch {
526 // keep the old id
527 }
528 S.label = basename(S.cwd)
529 return next(e)
530 })
531
532 on('session.end', async ($, e, next) => {
533 if (S.id) await $.store.delete('hb:' + S.id)
534 return next(e)
535 })
536
537 // The first prompt names the session on the board
538 on('prompt.submit', async ($, e, next) => {
539 now = await $.clock.now()
540 if (S.label === basename(S.cwd) && e.origin && e.origin.kind === 'composer' && e.text) {
541 S.label = basename(S.cwd) + ' · ' + clip(e.text, 40)
542 }
543 // A new message in this chat makes a waiting handoff out of date (its file stays)
544 if (H.text && !H.continuing && e.origin && e.origin.kind === 'composer' && !String(e.text || '').trim().startsWith('/')) {
545 H.text = ''
546 H.path = ''
547 H.fromFile = false
548 $.ui.invalidate('ui.render')
549 }
550 const ttl = ttlMin()
551 const left = msLeft(S.lastActivity, ttl)
552 const isCold = left !== null && left <= 0
553 const fromUser = e.origin && (e.origin.kind === 'composer' || e.origin.kind === 'bridge')
554 if (!settings.guard || !fromUser || !isCold || S.ctx < settings.bigTokens || e.turnId) return next(e)
555 const cost = money(rewriteCost(S.ctx, S.model, ttl))
556 let answer = 'Send anyway'
557 try {
558 answer = await $.ui.ask(
559 `Cache went cold ${minutes(-left)} ago. Sending now rewrites ${tokens(S.ctx)} tokens of context (about ${cost}). What should happen?`,
560 ['Send anyway', 'Compact first, then send', 'Cancel'],
561 )
562 } catch {
563 // nobody to ask (a -p run, or the dialog was dismissed): send as typed
564 return next(e)
565 }
566 if (answer === 'Compact first, then send') {
567 try {
568 await $.session.compact()
569 } catch {
570 // compaction refused or failed: send anyway
571 }
572 return next(e)
573 }
574 if (answer === 'Send anyway') return next(e)
575 $.ui.toast('Not sent. A fresh session with a short handoff avoids the rewrite entirely.', { timeoutMs: 8000 })
576 return { drop: 'Cancelled by cache-status before a cold cache rewrite' }
577 })
578
579 on('turn.start', async ($, e, next) => {
580 now = await $.clock.now()
581 S.working = true
582 if (e.turnId) S.turnId = e.turnId
583 await heartbeat($, true)
584 return next(e)
585 })
586
587 // Each main-loop request: did it read the cache, or rewrite it?
588 on('turn.step', async function* ($, e, next) {
589 const startedAt = await $.clock.now()
590 const result = yield* next(e)
591 if (e.agentId || !result || !result.usage) return result
592 now = await $.clock.now()
593 const u = result.usage
594 const total = totalInput(u)
595 const written = u.cache_creation_input_tokens || 0
596 const gap = S.lastActivity ? startedAt - S.lastActivity : 0
597 S.model = u.model || S.model
598 const afterCompact = justCompacted
599 justCompacted = false
600 if (afterCompact) {
601 // the first request after a compaction writes the new, shorter context: expected
602 } else if (S.lastActivity && total > 30000 && written / total > 0.5) {
603 // A rewrite after 5-60 idle minutes means this session runs on the 5-minute TTL
604 if (!settings.ttlMin && gap > 5.5 * MIN && gap < S.ttlMin * MIN) {
605 S.ttlMin = 5
606 S.ttlSource = 'measured'
607 }
608 S.coldRestarts.push({ at: now, tokens: written, usd: rewriteCost(written, S.model, ttlMin()), gapMin: Math.round(gap / MIN) })
609 } else if (S.lastActivity && total > 30000 && gap > 5.5 * MIN && cachedShare(u) > 0.8) {
610 // A hit after more than 5 idle minutes proves the 1-hour TTL
611 if (!settings.ttlMin) {
612 S.ttlMin = 60
613 S.ttlSource = 'measured'
614 }
615 }
616 S.ctx = total
617 S.lastActivity = now
618 return result
619 })
620
621 on('turn.complete', async ($, e, next) => {
622 const r = await next(e)
623 if (e.agentId) return r
624 now = await $.clock.now()
625 S.working = false
626 S.waitingSince = 0
627 S.tool = ''
628 S.lastTurnEnd = now
629 try {
630 const usage = await $.session.usage()
631 if (usage.context && usage.context.tokens) S.ctx = usage.context.tokens
632 if (usage.context && usage.context.window) S.window = usage.context.window
633 if (usage.cost) S.costUsd = usage.cost.usd
634 if (Array.isArray(usage.rateLimits) && usage.rateLimits.length) S.rateLimits = usage.rateLimits
635 } catch {
636 // usage unavailable: keep the per-request figures
637 }
638 if (H.file && (await captureHandoffFile($))) {
639 // the file is the handoff; the turn's answer is only its summary
640 } else if (H.armed && e.turnId !== H.notTurn) await captureHandoff($, e)
641 await heartbeat($, true)
642 $.ui.invalidate('ui.render')
643 return r
644 })
645
646 on('session.compact', async ($, e, next) => {
647 const r = await next(e)
648 justCompacted = true
649 return r
650 })
651
652 on('session.measure', async ($, e, next) => {
653 if (e.context && e.context.tokens) S.ctx = e.context.tokens
654 if (e.context && e.context.window) S.window = e.context.window
655 if (e.cost) S.costUsd = e.cost.usd
656 if (Array.isArray(e.rateLimits) && e.rateLimits.length) S.rateLimits = e.rateLimits
657 return next(e)
658 })
659
660 // Waiting on the person: a permission prompt or a question
661 on('tool.check', async ($, e, next) => {
662 const d = await next(e)
663 if (d && d.decision === 'ask' && !S.waitingSince) {
664 now = await $.clock.now()
665 S.waitingSince = now
666 S.waitingFor = 'permission for ' + e.tool
667 }
668 return d
669 })
670
671 on('tool.call', async ($, e, next) => {
672 if (!e.agentId) S.tool = e.tool
673 // Claude calling the handoff skill itself: this turn's answer is the handoff
674 if (!e.agentId && e.tool === 'Skill' && HANDOFF_SKILL.test(String(e.skill || ''))) armHandoff(true)
675 if (!e.agentId) noteHandoffFile(e)
676 if (e.tool === 'AskUserQuestion') {
677 now = await $.clock.now()
678 S.waitingSince = now
679 S.waitingFor = 'a question'
680 }
681 try {
682 return await next(e)
683 } finally {
684 if (S.waitingSince) {
685 S.waitingSince = 0
686 S.waitingFor = ''
687 }
688 }
689 })
690
691 // /session-handoff from anywhere (typed, the band, /handoff): watch for its answer
692 on('command.run', async ($, e, next) => {
693 if (HANDOFF_SKILL.test(String(e.command || ''))) armHandoff(false)
694 return next(e)
695 })
696
697 // /handoff runs the handoff and then asks to clear and continue (the band's
698 // buttons draw only in the terminal); /handoff continue does the second half.
699 // Both run off the hook: a command started inside a hook the session is
700 // waiting on is refused.
701 on('command.run', { command: ['handoff', 'ck-handoff'] }, async ($, e) => {
702 now = await $.clock.now()
703 const arg = String(e.args || '').trim().toLowerCase()
704 if (arg === 'continue' || arg === 'go') {
705 if (!H.text) return { text: `No handoff ready yet. Type /${names.handoff} to make one.` }
706 $.clock.after(50, () => clearAndContinue($).catch(() => {}))
707 return { text: 'Clearing this chat and continuing with the handoff.' }
708 }
709 H.askAfter = true
710 $.clock.after(50, () => runHandoff($).catch(() => {}))
711 return { text: 'Running the session handoff. When it finishes, you can clear this chat and continue with it.' }
712 })
713
714 on('command.run', { command: ['cache', 'ck-cache'] }, async ($, e) => {
715 now = await $.clock.now()
716 const [key, value] = String(e.args || '').trim().toLowerCase().split(/\s+/)
717 if (key === 'ttl') {
718 settings.ttlMin = value === '5' ? 5 : value === '60' ? 60 : 0
719 } else if (key === 'guard' || key === 'alerts') {
720 // no value toggles
721 settings[key] = value === undefined ? !settings[key] : value !== 'off'
722 } else if (key === 'big') {
723 const n = parseTokens(value)
724 if (n) settings.bigTokens = n
725 }
726 if (key) {
727 await $.store.set('settings', settings)
728 $.ui.invalidate('ui.render')
729 }
730 return { text: statusText() }
731 })
732
733 on('command.run', { command: ['keepwarm', 'ck-keepwarm'] }, async ($, e) => {
734 now = await $.clock.now()
735 const arg = String(e.args || '').trim().toLowerCase()
736 if (arg === 'off' || (arg === '' && S.keepWarm)) {
737 stopKeepWarm($, 'turned off')
738 } else {
739 const hours = Number(arg) > 0 ? Math.min(24, Number(arg)) : settings.keepWarmHours
740 startKeepWarm($, hours)
741 }
742 await heartbeat($, true)
743 $.ui.invalidate('ui.render')
744 return {}
745 })
746
747 on('command.run', { command: ['board', 'ck-board'] }, async ($, e) => {
748 now = await $.clock.now()
749 await heartbeat($, true)
750 await readFleet($)
751 const surface = await $.session.surface()
752 if (!surface) return { text: boardText() }
753 paneOpen = true
754 await $.ui.open({ id: PANE, title: 'Sessions', focus: true, closeOnEscape: true })
755 return {}
756 })
757
758 on('ui.close', async ($, e, next) => {
759 if (e.id === PANE) paneOpen = false
760 return next(e)
761 })
762
763 // The band above the prompt: this session's cache at a glance
764 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
765 const below = await next(e)
766 if (e.props && e.props.hasSurvey) return below
767 if (!S.lastActivity && !S.keepWarm) return below
768 const { Box, Text, Button } = $.ui.resolve(e)
769 const hb = myHeartbeat()
770 const st = cacheState(hb)
771 const ttl = ttlMin()
772 const big = S.ctx >= settings.bigTokens
773 const parts = []
774 const tip = (scope) => ({ scope, underline: true })
775 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)}`] }))
776 else if (st.kind === 'warm') parts.push(Text({ color: 'green', hover: tip('ck-c'), children: [`● c ${minutes(st.left)}`] }))
777 else if (st.kind === 'cooling') parts.push(Text({ color: 'yellow', bold: true, hover: tip('ck-c'), children: [`◐ c cools in ${minutes(st.left)}`] }))
778 else if (st.kind === 'cold') parts.push(Text(big ? { color: 'red', bold: true, hover: tip('ck-c'), children: [`○ c cold ${minutes(-st.left)}`] } : { dimColor: true, hover: tip('ck-c'), children: [`○ c cold ${minutes(-st.left)}`] }))
779 parts.push(Text({ dimColor: true, children: [` │ ctx ${tokens(S.ctx)}`] }))
780 if (st.kind === 'cold' && big) parts.push(Text({ color: 'red', hover: tip('ck-rewrite'), children: [` │ next send rewrites it ≈ ${money(rewriteCost(S.ctx, S.model, ttl))}`] }))
781 else parts.push(Text({ dimColor: true, hover: tip('ck-rewrite'), children: [` │ rwc ≈ ${money(rewriteCost(S.ctx, S.model, ttl))}`] }))
782 parts.push(Text({ dimColor: true, hover: tip('ck-sc'), children: [` │ sc ${money(S.costUsd)}`] }))
783 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))}`] }))
784 if (S.rateLimits.length) parts.push(Text(limitTone(S.rateLimits, { children: [' │ ' + limitsText(S.rateLimits)] })))
785 const row = [Box({ flexDirection: 'row', children: parts })]
786 if (st.kind === 'cooling' && big) {
787 row.push(Button({ key: 'keepwarm', label: 'keep warm', hotkey: '1', plain: true, onPress: async () => { now = await $.clock.now(); startKeepWarm($, settings.keepWarmHours); await heartbeat($, true); $.ui.invalidate('ui.render') } }))
788 } else if (st.kind === 'kept') {
789 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') } }))
790 }
791 const left = Box({ flexDirection: 'row', columnGap: 2, children: row })
792 // Right edge: one click runs /session-handoff (h while the band has the focus;
793 // a letter never fires from the prompt box, unlike a digit). Once a handoff
794 // is in, c clears this chat and sends it as the fresh chat's first prompt.
795 const busy = handoffPending || H.armed
796 const handoff = Button({ key: 'handoff', label: handoffPending ? 'handoff queued' : H.armed ? 'handoff running' : 'handoff', hotkey: 'h', plain: true, dimColor: busy, onPress: () => runHandoff($) })
797 let right = handoff
798 if (H.text || H.continuing) {
799 const go = Button({ key: 'continue', label: H.continuing ? 'clearing' : 'clear and continue', hotkey: 'c', plain: true, onPress: () => clearAndContinue($) })
800 right = Box({ flexDirection: 'row', columnGap: 2, children: [go, handoff] })
801 }
802 const mine = Box({ flexDirection: 'row', justifyContent: 'space-between', width: '100%', columnGap: 2, children: [left, right] })
803 // Hover help rows sit above the band row, so revealing one leaves the hovered text in place.
804 const help = (scope, text) => Box({ display: 'none', hover: { scope, display: 'flex' }, children: [Text({ dimColor: true, italic: true, children: [text] })] })
805 const tips = [
806 help('ck-c', `cache: prompt cache state and time until it expires${ttl ? ` (${ttl}m TTL)` : ''}; once cold, the next send writes the whole context again`),
807 help('ck-rewrite', `rewrite: cost to write ${tokens(S.ctx)} of context into the cache again if it expires (cache write price${ttl ? `, ${ttl}m TTL` : ''})`),
808 help('ck-sc', 'session cost: what this session has cost so far, at API list prices'),
809 ]
810 return Box({ flexDirection: 'column', children: below ? [...tips, mine, below] : [...tips, mine] })
811 })
812
813 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
814 if (e.requestId !== PANE) return next(e)
815 return drawBoard($, e)
816 })
817
818 // The band is terminal-only. The footer also draws in the Desktop app, so it
819 // carries a short label, only when there's something to act on.
820 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
821 const label = footerLabel()
822 if (!label) return next(e)
823 const modes = Array.isArray(e.props && e.props.modes) ? e.props.modes : []
824 return next({ ...e, props: { ...e.props, modes: [...modes, label] } })
825 })
826}
827
828function footerLabel() {
829 const parts = []
830 const st = cacheState(myHeartbeat())
831 const big = S.ctx >= settings.bigTokens
832 if (st.kind === 'kept') parts.push('cache kept warm')
833 else if (st.kind === 'cooling' && big) parts.push(`cache cools in ${minutes(st.left)} · /keepwarm`)
834 else if (st.kind === 'cold' && big) parts.push(`cache cold · rewrite ≈ ${money(rewriteCost(S.ctx, S.model, ttlMin()))}`)
835 const high = S.rateLimits.filter((l) => (l.percentUsed || 0) >= 80)
836 if (high.length) parts.push(limitsText(high))
837 if (H.text) parts.push(`handoff ready · /${names.handoff} continue`)
838 return parts.join(' · ')
839}
840
841function sortFleet(list) {
842 const rank = { waiting: 0, working: 1, idle: 2 }
843 return [...list].sort((a, b) => {
844 const r = (rank[a.state] ?? 3) - (rank[b.state] ?? 3)
845 if (r !== 0) return r
846 const la = msLeft(a.lastActivity, a.ttlMin) ?? Infinity
847 const lb = msLeft(b.lastActivity, b.ttlMin) ?? Infinity
848 return la - lb
849 })
850}
851
852function cacheCell(hb) {
853 const st = cacheState(hb)
854 if (st.kind === 'kept') return ['kept warm', 'cyan']
855 if (st.kind === 'warm') return ['warm ' + minutes(st.left), 'green']
856 if (st.kind === 'cooling') return ['cools in ' + minutes(st.left), 'yellow']
857 if (st.kind === 'cold') return ['cold ' + minutes(-st.left), (hb.ctx || 0) >= settings.bigTokens ? 'red' : 'gray']
858 return ['–', 'gray']
859}
860
861function stateCell(hb) {
862 if (hb.stale) return ['? silent', 'gray']
863 if (hb.state === 'waiting') return ['⏳ waiting', 'magenta']
864 if (hb.state === 'working') return ['● working', 'cyan']
865 return ['○ idle', 'gray']
866}
867
868// Text props for a colour name; 'gray' draws dim, which every theme has
869function tone(color, extra = {}) {
870 return color === 'gray' ? { ...extra, dimColor: true } : { ...extra, color }
871}
872
873function boardText() {
874 const rows = sortFleet(fleet).map((hb) => {
875 const [state] = stateCell(hb)
876 const [cache] = cacheCell(hb)
877 return `${pad(state, 11)} ${pad(clip(mask(hb.label), 40), 41)} ${pad(tokens(hb.ctx), 6)} ${pad(cache, 13)} ${money(hb.costUsd)}${hb.agentsRunning ? ' ' + hb.agentsRunning + ' agents' : ''}${hb.self ? ' (this one)' : ''}`
878 })
879 return ['Sessions on this machine:', ...rows].join('\n')
880}
881
882function drawBoard($, e) {
883 const { Box, Text, Button } = $.ui.resolve(e)
884 const width = Math.max(60, (e.props && e.props.bodyColumns) || 100)
885 const labelWidth = Math.max(16, Math.min(44, width - 62))
886 const list = sortFleet(fleet)
887 const header = Text({ dimColor: true, children: [`${pad('STATE', 11)} ${pad('SESSION', labelWidth + 1)} ${pad('CONTEXT', 8)} ${pad('CACHE', 14)} ${pad('COST', 7)} AGENTS`] })
888 const rows = list.map((hb, i) => {
889 const [state, stateColor] = stateCell(hb)
890 const [cache, cacheColor] = cacheCell(hb)
891 const label = clip(mask(hb.label || hb.id), labelWidth) + (hb.self ? ' ◂' : '')
892 const ctx = tokens(hb.ctx) + (hb.window ? ' ' + Math.round((100 * (hb.ctx || 0)) / hb.window) + '%' : '')
893 const cells = [
894 Text(tone(stateColor, { children: [pad(state, 11) + ' '] })),
895 Text({ bold: !!hb.self, children: [pad(label, labelWidth + 1) + ' '] }),
896 Text({ children: [pad(ctx, 8) + ' '] }),
897 Text(tone(cacheColor, { children: [pad(cache, 14) + ' '] })),
898 Text({ children: [pad(money(hb.costUsd), 7) + ' '] }),
899 Text({ dimColor: !hb.agentsRunning, children: [String(hb.agentsRunning || 0) + ' '] }),
900 ]
901 const big = (hb.ctx || 0) >= settings.bigTokens
902 if (big && !hb.stale && i < 9) {
903 cells.push(
904 Button({
905 key: 'kw-' + hb.id,
906 label: hb.keepWarm ? 'stop warm' : 'keep warm',
907 hotkey: String(i + 1),
908 plain: true,
909 dimColor: !hb.keepWarm && cacheState(hb).kind !== 'cooling',
910 onPress: async () => {
911 now = await $.clock.now()
912 if (hb.self) {
913 if (S.keepWarm) stopKeepWarm($, 'turned off')
914 else startKeepWarm($, settings.keepWarmHours)
915 await heartbeat($, true)
916 } else {
917 await $.store.set('cmd:' + hb.id, { keepWarm: !hb.keepWarm, at: now })
918 $.ui.toast(`Sent to "${mask(hb.label)}". It applies within 30 seconds.`)
919 }
920 await readFleet($)
921 $.ui.invalidate('ui.render')
922 },
923 }),
924 )
925 }
926 const line = Box({ flexDirection: 'row', children: cells })
927 const detail = []
928 if (hb.state === 'waiting' && hb.waitingFor) detail.push(`needs you: ${hb.waitingFor}`)
929 else if (hb.state === 'working' && hb.tool) detail.push(`running ${hb.tool}`)
930 if (hb.coldCount) detail.push(`${hb.coldCount} cold restart${hb.coldCount === 1 ? '' : 's'} ${money(hb.coldUsd)}`)
931 if (hb.keepWarm) detail.push(`warm until ${clock(hb.keepWarmUntil)}, ${hb.pings} ping(s) ${money(hb.pingUsd)}`)
932 if (detail.length === 0) return line
933 return Box({ flexDirection: 'column', children: [line, Text({ dimColor: true, children: [' └ ' + detail.join(' · ')] })] })
934 })
935 const totalCost = list.reduce((a, hb) => a + (hb.costUsd || 0), 0)
936 const totalCold = list.reduce((a, hb) => a + (hb.coldUsd || 0), 0)
937 const waiting = list.filter((hb) => hb.state === 'waiting').length
938 const footer = Text({
939 dimColor: true,
940 children: [`${list.length} session${list.length === 1 ? '' : 's'} · ${money(totalCost)} total · ${waiting} waiting on you · cold restarts ${money(totalCold)} · keep warm reads the cache before it expires · Esc closes`],
941 })
942 return Box({ flexDirection: 'column', children: [header, ...rows, Text({ children: [' '] }), footer] })
943}
944hooks/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}
337