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