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 Claude Code mods, published as a plugin marketplace named claude-super-mods.
| Mod | What you get | ||||
|---|---|---|---|---|---|
| goal-meter 1.2.1 | A progress bar for /goal, built from Claude's own task plan. One line by default. /goals opens the full task list. | ||||
| cache-keeper 1.3.0 | A band above the prompt with the model, effort, context bar, plan limits (with the 5h reset) and cache state, so no status line is needed. Keeps a long chat's prompt cache warm (/keepwarm 2), asks before an expensive cold send, shows every local chat (/board), and hands a chat off (/handoff). Every compaction without its own instructions is told what to keep, and the band shows how much it saved. | ||||
| lean-explore 1.6.0 | Replaces the built-in Explore subagent (the one that does codebase searches) with a lean one: Haiku, low effort, Read/Grep/Glob only, short answers with file:line. It counts what it really did (searches, reads, tokens delegated to Haiku) from your own transcripts. `/lean-explore on\ | off\ | status\ | stats\ | rescan`. |
| memory-guard 0.2.2 | Spots sessions worth remembering (files written to output folders, MCP writes, meeting transcripts, decisions, commits, accepted plans, long chats) and nudges once to run /memorize. A session that ends without it is queued, and the next session in that project offers to catch up. Files into your wiki if you point it at one, else Claude Code's own auto-memory. Ships its own /memorize skill. |
A plugin cannot replace a built-in agent with an agent of its own (plugin agents get a lean-explore: prefix). So on the first session the mod writes ~/.claude/agents/Explore.md itself, marked with a comment line so it never touches an Explore.md you wrote.
Explore: Haiku · 6 searches counts this chat's Explore runs; hover it for a card with a Turn off button. Amber Explore: built-in means it is off, with a Turn on button. After a toggle it adds what the next session will use. It shares one row with the TL;DR button (the mods-row convention), chip on the left.Explore: Haiku or Explore: built-in, with the same next-session note./lean-explore stats prints all-time searches, reads and tokens delegated to Haiku (measured from usage figures; the "kept out of chat" number is an estimate at 4 characters per token). /lean-explore rescan rebuilds the totals from your transcripts./lean-explore status shows whether it is on, off, or blocked by your own Explore.md./lean-explore off renames it to Explore.md.off; the built-in Explore comes back./lean-explore on brings it back./lean-explore off, or delete ~/.claude/agents/Explore.md. Uninstalling the mod does not remove the file.When a search needs judgment (a review, a design call), ask for a general-purpose agent or pass model: "opus": Haiku finds things well but checks less of what it reads.
Each session scores a point per kind of durable work: a file written to an output folder (docs, reports, deliverables, notes), a write through any MCP tool, a meeting or transcript read, a decision in your prompt ("let's go with", "from now on"), a git commit, an accepted plan. Ten turns or more adds one. At 2 points it nudges once: a toast, a band above the prompt with Memorize now and Not this session, and /memorize as the prompt suggestion. A session that ends without /memorize goes on that project's queue. The next session there shows Catch up, and the hint line says · 3 unmemorized.
With no setup it files into Claude Code's auto-memory (~/.claude/projects/<project>/memory/ and its MEMORY.md). To use a wiki, or your own /memorize skill, add .claude/memory-guard.json to the project (or ~/.claude/memory-guard.json for all of them). Every field is optional:
{
"kb": { "path": "wiki", "index": "wiki/index.md", "log": "wiki/log.md", "rules": "wiki/CLAUDE.md" },
"skill": "memorize",
"threshold": 2,
"longSessionTurns": 10,
"outputDirs": ["docs", "reports", "deliverables", "notes"],
"signals": { "decision": false },
"extraSignals": [{ "name": "Tickets", "tool": "^mcp__.*linear.*__(create|update)" }, { "name": "Release", "bash": "npm publish" }],
"ui": { "band": true, "hint": true, "suggest": true }
}
kb: your knowledge base. The bundled skill reads its rules file and index, files pages there, and logs each one. A write to kb.log counts as memorized.skill: the skill the buttons run. Leave it out to use the bundled memory-guard:memorize.extraSignals: your own signals, by tool-name or Bash-command regex.On desktop a chip above the prompt shows Memory guard: on · 1 of 2 (green) or off (grey); hover it for a Turn off here / Turn on here button. The terminal footer says memory guard on or off. "ui": { "chip": false } hides both.
If it finds a wiki/ folder or an Obsidian vault with no config, it says so once. The band and hint line show in the terminal and the desktop app; elsewhere the toast and /memorize-queue still work.
goal-meter and cache-keeper are built on Nate Herk's claude-code-mods (MIT). goal-meter is a fork of his goal-meter 1.0.0, and cache-keeper is based on his cache-keeper 1.0.0. Each mod's CHANGES.md lists what differs from upstream. cache-keeper 1.3.0 also borrows three ideas from JayGPTPro's Smart PreCompact. MIT, see LICENSE.
Needs a Claude Code build with mods (function-hook plugins). Works on Windows, macOS and Linux.
claude plugin marketplace add bStyler/claude-mods
claude plugin install goal-meter@claude-super-mods
claude plugin install cache-keeper@claude-super-mods
claude plugin install lean-explore@claude-super-mods
claude plugin install memory-guard@claude-super-mods
Then quit Claude Code and start it again. /reload-plugins and /clear do not load mod code. Check: type /goals. It should open the goal meter pane.
If Nate Herk's nateherk-mods is installed on that machine, turn off its copies first (same names, same tools):
claude plugin disable goal-meter@nateherk-mods
claude plugin disable cache-keeper@nateherk-mods
cache-keeper's band replaces a custom status line. If you had one, remove the statusLine block from ~/.claude/settings.json so the same figures don't show twice.
goal-meter
| Command | Does |
|---|---|
/goal <what done looks like> | Starts a goal. Claude plans it as sized tasks and the bar tracks them. |
/goals | Full task list, plus other chats' goals from the last 12 hours. |
/goals compact / /goals full | One-line bar (default) or the full bar. Saved for every chat. |
/goals hide / /goals show | Hide the bar and footer label in every chat, or bring them back. |
/goals clear | Remove this chat's goal, running or finished. |
/goals strict on | No file edits in a /goal until Claude has written the plan. Useful for coding. |
The g key on the bar only works after Ctrl+X Tab focuses the bar. From the prompt, type /goals.
cache-keeper
| Command | Does |
|---|---|
/keepwarm 2 / /keepwarm off | Keep this chat's cache warm for N hours (max 24). |
/cache big 100k | Size at which it asks before a cold send (default 150k). |
/cache guard off | Turn the cold-send question off. |
/cache ttl 5 / 60 / auto | Cache window, if it guesses wrong. |
/board | Every local chat on one board. |
/handoff | Write a handoff and continue in a fresh chat. |
memory-guard
| Command | Does |
|---|---|
/memorize | Save what this session learned (bundled skill: memory-guard:memorize). |
/memorize queue | Catch up on the queued sessions from their transcripts. |
/memorize-queue / /memorize-queue clear | Show this session's score and the queue, or clear it. |
/memory-guard off / on / status | Stop or start watching this project; show where it files and what it counts. |
claude plugin marketplace update claude-super-mods
claude plugin update goal-meter@claude-super-mods
claude plugin update cache-keeper@claude-super-mods
claude plugin update lean-explore@claude-super-mods
claude plugin update memory-guard@claude-super-mods
Restart Claude Code afterwards. When changing code, raise version in the mod's .claude-plugin/plugin.json, or plugin update will not pick it up.
claude plugin uninstall goal-meter@claude-super-mods
claude plugin uninstall cache-keeper@claude-super-mods
claude plugin uninstall lean-explore@claude-super-mods
claude plugin uninstall memory-guard@claude-super-mods
claude plugin marketplace remove claude-super-mods
Saved state lives in ~/.claude/mods-data/ and can be deleted.
hooks/register.mjs 1085 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, bar } from './fmt.mjs'
13import { makeMasker } from './privacy.mjs'
14
15const MIN = 60000
16const HEARTBEAT_EVERY = 30000
17const PANE = 'cache-board'
18const RECEIPT_MS = 10 * MIN
19// What a compaction without instructions of its own is told to keep: the details
20// a plain summary drops first. Idea from JayGPTPro's Smart PreCompact.
21const KEEP_LIST =
22 "Keep every approach that failed and why, every decision with its reason, the user's instructions and corrections in their own words, what is half done or untested, and exact strings (paths, errors, commands, IDs). Compress routine file reads, drafts and finished debugging rounds."
23
24// This session
25const S = {
26 id: '',
27 cwd: '',
28 label: '',
29 model: '',
30 effort: '', // the main loop's reasoning effort, from its last request
31 lastActivity: 0, // last main-loop request or keep-warm ping that touched the cache
32 ctx: 0,
33 window: 0,
34 costUsd: 0,
35 ttlMin: 60,
36 ttlSource: 'default',
37 coldRestarts: [],
38 working: false,
39 turnId: '', // the main loop's running (or last) turn
40 waitingSince: 0,
41 waitingFor: '',
42 tool: '',
43 lastTurnEnd: 0,
44 keepWarm: false,
45 keepWarmUntil: 0,
46 pings: 0,
47 pingUsd: 0,
48 lastPingAt: 0,
49 warnedFor: 0,
50 agentsRunning: 0,
51 rateLimits: [],
52}
53
54const settings = { bigTokens: 150000, guard: true, ttlMin: 0, alerts: true, keepWarmHours: 4 }
55const names = { cache: 'cache', keepwarm: 'keepwarm', board: 'board', handoff: 'handoff' }
56
57let now = 0
58let home = ''
59let lastHeartbeat = 0
60let rec = { on: false, strict: false }
61let mask = (s) => s
62let fleet = [] // heartbeats of every session, this one included
63const alerted = new Map() // sessionId -> state or warning key already announced
64let paneOpen = false
65let justCompacted = false
66let handoffPending = false
67let compactions = 0
68let receipt = null // { before, after?, at } of the last compaction, shown on the band
69
70function noteCompact(r) {
71 compactions++
72 const before = (r && r.tokensBefore) || S.ctx
73 receipt = before ? { before, after: r && r.tokensAfter, at: now } : null
74}
75
76function liveReceipt() {
77 if (receipt && now - receipt.at > RECEIPT_MS) receipt = null
78 return receipt
79}
80
81function receiptText(r) {
82 if (r.after === undefined) return `✓ Compacted ${tokens(r.before)} · size after your next message`
83 return `✓ Compacted ${tokens(r.before)} → ${tokens(r.after)} · ${tokens(Math.max(0, r.before - r.after))} lighter`
84}
85
86// The handoff flow. When /session-handoff runs (the band's button, /handoff, a
87// typed command, or Claude calling the skill), the answer of the turn it starts
88// is captured and saved to a file. "clear and continue" then runs /clear and
89// sends the handoff as the fresh chat's first prompt.
90const HANDOFF_SKILL = /(^|:)session-handoff$/
91const H = {
92 armed: false,
93 notTurn: '', // a turn already running when the handoff was queued: not the handoff's
94 askAfter: false, // /handoff asks "clear and continue?" once the handoff is in
95 text: '',
96 path: '',
97 continuing: false,
98}
99
100function armHandoff(insideTurn) {
101 H.armed = true
102 H.notTurn = insideTurn ? '' : S.working ? S.turnId : ''
103}
104
105// The handoff without any preamble Claude put before its heading
106function handoffBody(answer) {
107 const text = String(answer || '').trim()
108 const i = text.indexOf('# Session Handoff')
109 return i > 0 ? text.slice(i) : text
110}
111
112function fileStamp(ms) {
113 const d = new Date(ms)
114 const two = (n) => String(n).padStart(2, '0')
115 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}`
116}
117
118async function saveHandoff($, text) {
119 const project = basename(S.cwd).replace(/[^A-Za-z0-9-]+/g, '-') || 'session'
120 const path = `${home || '.'}/.claude/mods-data/cache-keeper/handoffs/${fileStamp(now)}-${project}-${S.id.slice(0, 8)}.md`.replace(/\\/g, '/')
121 await $.fs.write(path, text + '\n')
122 return path
123}
124
125async function captureHandoff($, e) {
126 H.armed = false
127 H.notTurn = ''
128 const askAfter = H.askAfter
129 H.askAfter = false
130 const body = e.reason === 'answer' ? handoffBody(e.answer) : ''
131 if (body.length < 200) {
132 $.ui.toast('The handoff turn ended without a handoff, so nothing was saved.', { timeoutMs: 8000 })
133 return
134 }
135 H.text = body
136 try {
137 H.path = await saveHandoff($, body)
138 } catch {
139 H.path = ''
140 }
141 const saved = H.path ? `Handoff saved to ${H.path}.` : 'Handoff captured (the backup file could not be written).'
142 $.ui.toast(`${saved} Press c on the band or type /${names.handoff} continue to clear and continue.`, { timeoutMs: 15000 })
143 // Off the hook: the turn is ending, and a dialog would hold it open
144 if (askAfter) $.clock.after(300, () => offerContinue($).catch(() => {}))
145}
146
147async function offerContinue($) {
148 if (!H.text) return
149 let answer = ''
150 try {
151 answer = await $.ui.ask('Handoff saved. Clear this chat and continue with it in a fresh context?', ['Clear and continue', 'Keep this chat'])
152 } catch {
153 return // dismissed: the band button and /handoff continue still work
154 }
155 if (answer === 'Clear and continue') await clearAndContinue($)
156}
157
158function continuationPrompt() {
159 const where = H.path ? ` (saved at ${H.path})` : ''
160 return `Handoff from my previous session${where}:\n\n${H.text}`
161}
162
163async function restorePrompt($, text, why) {
164 let filled = false
165 try {
166 filled = !!(await $.prompt.fill({ text })).isFilled
167 } catch {
168 filled = false
169 }
170 $.ui.toast(`${why} Not sent${filled ? ': your message is back in the prompt box' : ''}.`, { timeoutMs: 10000 })
171}
172
173// /compact as if typed. Our session.compact hook sees it and notes it, so the
174// count says whether it ran.
175async function compactByCommand($) {
176 const before = compactions
177 try {
178 await $.command.run({ command: 'compact', args: KEEP_LIST })
179 } catch {
180 return false
181 }
182 return compactions > before
183}
184
185async function compactThenSend($, resend) {
186 let result
187 for (let attempt = 0; ; attempt++) {
188 try {
189 result = await $.session.compact({ instructions: KEEP_LIST })
190 break
191 } catch (err) {
192 const why = String((err && err.message) || err)
193 // A turn still running: two short waits. The desktop app runs the session
194 // headless and refuses a mod's own compaction outright.
195 if (attempt < 2 && !/headless/i.test(why)) {
196 await $.clock.sleep(1000)
197 continue
198 }
199 // Last resort: /compact as if typed, which the person's session allows
200 if (await compactByCommand($)) break
201 await restorePrompt($, resend.text, 'Compaction failed: ' + clip(why, 100) + '.')
202 return
203 }
204 }
205 if (result && result.skip) {
206 await restorePrompt($, resend.text, 'Another hook stopped the compaction.')
207 return
208 }
209 // our own compaction skips our session.compact hook, so mark it here
210 // (a /compact by command went through the hook already)
211 if (result) {
212 justCompacted = true
213 now = await $.clock.now()
214 noteCompact(result)
215 $.ui.invalidate('ui.render')
216 }
217 S.lastActivity = await $.clock.now()
218 try {
219 await $.prompt.submit(resend)
220 } catch {
221 await restorePrompt($, resend.text, 'Compacted, but the message could not be sent.')
222 }
223}
224
225async function clearAndContinue($) {
226 if (H.continuing) return
227 if (!H.text) {
228 $.ui.toast(`No handoff ready. Press h on the band or type /${names.handoff} first.`, { timeoutMs: 8000 })
229 return
230 }
231 if (S.working) {
232 $.ui.toast('Claude is still working. Clear and continue once this turn ends.', { timeoutMs: 8000 })
233 return
234 }
235 const text = continuationPrompt()
236 const path = H.path
237 H.continuing = true
238 $.ui.invalidate('ui.render')
239 try {
240 await $.command.run({ command: 'clear', args: '' })
241 } catch (err) {
242 H.continuing = false
243 $.ui.invalidate('ui.render')
244 $.ui.toast('Could not run /clear: ' + clip(String((err && err.message) || err), 100) + (path ? ` The handoff is saved at ${path}.` : ''), { timeoutMs: 10000 })
245 return
246 }
247 H.text = ''
248 H.path = ''
249 try {
250 await $.prompt.submit({ text, asUser: true })
251 } catch {
252 // Not sent: leave it in the prompt box for one Enter
253 let filled = false
254 try {
255 filled = !!(await $.prompt.fill({ text })).isFilled
256 } catch {
257 filled = false
258 }
259 $.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 })
260 } finally {
261 H.continuing = false
262 $.ui.invalidate('ui.render')
263 }
264}
265
266// Runs the /session-handoff skill as if you typed it. The engine queues it
267// until Claude finishes the current turn.
268async function runHandoff($) {
269 if (handoffPending) {
270 $.ui.toast('Session handoff is already queued.')
271 return
272 }
273 try {
274 const commands = await $.command.list()
275 const cmd = commands.find((c) => c.name === 'session-handoff') || commands.find((c) => HANDOFF_SKILL.test(c.name))
276 if (!cmd) {
277 H.askAfter = false
278 $.ui.toast('No /session-handoff skill in this session.', { timeoutMs: 8000 })
279 return
280 }
281 handoffPending = true
282 armHandoff(false)
283 $.ui.invalidate('ui.render')
284 $.ui.toast(S.working ? 'Session handoff queued: it runs when Claude finishes this turn.' : 'Running /session-handoff.')
285 await $.command.run({ command: cmd.name, args: '' })
286 } catch (err) {
287 H.armed = false
288 H.askAfter = false
289 $.ui.toast('Could not start /session-handoff: ' + clip(String((err && err.message) || err), 100), { timeoutMs: 8000 })
290 } finally {
291 handoffPending = false
292 $.ui.invalidate('ui.render')
293 }
294}
295
296function ttlMin() {
297 return settings.ttlMin || S.ttlMin
298}
299
300// Plan limit windows as the API reports them: five_hour, seven_day, spend_limit
301const LIMIT_LABEL = { five_hour: '5h', seven_day: 'week', spend_limit: 'spend' }
302
303function limitsText(limits) {
304 return limits
305 .map((l) => {
306 const reset = l.kind === 'five_hour' && l.resetsAt ? Date.parse(l.resetsAt) : NaN
307 return `${LIMIT_LABEL[l.kind] || l.kind} ${Math.round(l.percentUsed)}%${reset ? ` (resets ${clock(reset)})` : ''}`
308 })
309 .join(' · ')
310}
311
312// "claude-opus-5-5[1m]" -> "Opus 5.5"; anything else as given
313function modelLabel(model) {
314 const m = String(model || '').match(/^(?:claude-)?([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/i)
315 if (!m) return clip(model, 24)
316 return m[1][0].toUpperCase() + m[1].slice(1) + ' ' + m[2] + (m[3] ? '.' + m[3] : '')
317}
318
319// The context fill as the status line drew it: bar, percent, used/window
320function ctxText(withBar) {
321 if (!S.window) return `ctx ${tokens(S.ctx)}`
322 const fill = S.ctx / S.window
323 const used = `${Math.round(100 * fill)}% ${tokens(S.ctx)}/${tokens(S.window).replace('.0', '')}`
324 return withBar ? `ctx ${bar(fill, 10)} ${used}` : `ctx ${used}`
325}
326
327// $.session.usage() and session.measure carry the status line's own figures
328function applyUsage(u) {
329 if (!u) return
330 if (u.context && u.context.tokens) S.ctx = u.context.tokens
331 if (u.context && u.context.window) S.window = u.context.window
332 if (u.cost) S.costUsd = u.cost.usd
333 if (Array.isArray(u.rateLimits) && u.rateLimits.length) S.rateLimits = u.rateLimits
334 // The first reading below the before-count is the size after the compaction
335 if (receipt && receipt.after === undefined && S.ctx && S.ctx < receipt.before) receipt.after = S.ctx
336}
337
338function limitTone(limits, extra) {
339 const top = Math.max(...limits.map((l) => l.percentUsed || 0))
340 if (top >= 95) return { ...extra, color: 'red', bold: true }
341 if (top >= 80) return { ...extra, color: 'yellow' }
342 return { ...extra, dimColor: true }
343}
344
345function money(v) {
346 return rec.strict ? '$•••' : usd(v)
347}
348
349function msLeft(lastActivity, ttl) {
350 if (!lastActivity) return null
351 return ttl * MIN - (now - lastActivity)
352}
353
354function cacheState(hb) {
355 const left = msLeft(hb.lastActivity, hb.ttlMin)
356 if (left === null) return { kind: 'unknown', left: 0 }
357 if (hb.keepWarm) return { kind: 'kept', left }
358 if (left <= 0) return { kind: 'cold', left }
359 if (left <= 5 * MIN) return { kind: 'cooling', left }
360 return { kind: 'warm', left }
361}
362
363function myHeartbeat() {
364 const coldUsd = S.coldRestarts.reduce((a, c) => a + c.usd, 0)
365 let state = 'idle'
366 if (S.waitingSince && now - S.waitingSince > 5000) state = 'waiting'
367 else if (S.working) state = 'working'
368 return {
369 id: S.id,
370 label: S.label,
371 cwd: S.cwd,
372 model: S.model,
373 state,
374 waitingFor: S.waitingFor,
375 tool: S.tool,
376 ctx: S.ctx,
377 window: S.window,
378 ttlMin: ttlMin(),
379 lastActivity: S.lastActivity,
380 keepWarm: S.keepWarm,
381 keepWarmUntil: S.keepWarmUntil,
382 pings: S.pings,
383 pingUsd: S.pingUsd,
384 costUsd: S.costUsd,
385 coldCount: S.coldRestarts.length,
386 coldUsd,
387 agentsRunning: S.agentsRunning,
388 lastTurnEnd: S.lastTurnEnd,
389 updatedAt: now,
390 }
391}
392
393async function registerCommand($, name, description, argumentHint, immediate) {
394 const spec = immediate ? { name, description, argumentHint, immediate: true } : { name, description, argumentHint }
395 try {
396 await $.command.register(spec)
397 return name
398 } catch {
399 try {
400 await $.command.register({ ...spec, name: 'ck-' + name })
401 return 'ck-' + name
402 } catch {
403 return null
404 }
405 }
406}
407
408async function readRecording($) {
409 try {
410 const path = (home || '.') + '/.claude/mods-data/recording.json'
411 if (!(await $.fs.exists(path))) {
412 rec = { on: false, strict: false }
413 } else {
414 const flag = JSON.parse(await $.fs.read(path))
415 rec = { on: !!flag.on, strict: !!flag.on && !!flag.strict }
416 }
417 } catch {
418 rec = { on: false, strict: false }
419 }
420 mask = rec.on ? makeMasker({ strict: rec.strict }) : (s) => s
421}
422
423async function heartbeat($, force) {
424 if (!S.id) return
425 if (!force && now - lastHeartbeat < 5000) return
426 lastHeartbeat = now
427 try {
428 const agents = await $.agent.list()
429 S.agentsRunning = agents.filter((a) => a.status === 'running').length
430 } catch {
431 // agent list unavailable: keep the last count
432 }
433 await $.store.set('hb:' + S.id, myHeartbeat())
434}
435
436async function readFleet($) {
437 const keys = (await $.store.keys()).filter((k) => k.startsWith('hb:'))
438 const list = []
439 for (const k of keys) {
440 const hb = await $.store.get(k)
441 if (!hb || typeof hb !== 'object') continue
442 if (hb.id === S.id) continue
443 // a session that stopped heartbeating an hour ago has ended or crashed
444 if (now - (hb.updatedAt || 0) > 60 * MIN) {
445 await $.store.delete(k)
446 continue
447 }
448 list.push({ ...hb, stale: now - (hb.updatedAt || 0) > 3 * MIN })
449 }
450 list.push({ ...myHeartbeat(), self: true })
451 fleet = list
452}
453
454// Commands another session's board sent this one, through the shared store
455async function applyRemoteCommands($) {
456 const c = await $.store.get('cmd:' + S.id)
457 if (!c) return
458 await $.store.delete('cmd:' + S.id)
459 if (c.keepWarm === true) startKeepWarm($, settings.keepWarmHours)
460 if (c.keepWarm === false) stopKeepWarm($, 'stopped from the board')
461}
462
463function startKeepWarm($, hours) {
464 S.keepWarm = true
465 S.keepWarmUntil = now + hours * 60 * MIN
466 $.ui.toast(`Keeping this cache warm until ${clock(S.keepWarmUntil)}. A ping reads the cache before it expires.`)
467}
468
469function stopKeepWarm($, why) {
470 if (!S.keepWarm) return
471 S.keepWarm = false
472 $.ui.toast(`Keep warm off (${why}). ${S.pings} ping(s), ${money(S.pingUsd)}.`, { timeoutMs: 8000 })
473}
474
475async function keepWarmStep($) {
476 if (!S.keepWarm) return
477 if (now >= S.keepWarmUntil) return stopKeepWarm($, 'time limit reached')
478 if (S.working || !S.lastActivity) return
479 const ttl = ttlMin()
480 const margin = ttl >= 60 ? 8 * MIN : 90000
481 if (now - S.lastActivity < ttl * MIN - margin) return
482 let reply = null
483 try {
484 reply = await $.model.fork({ prompt: 'cache-keeper keep-alive ping. Reply with only: ok' })
485 } catch {
486 reply = null
487 }
488 if (!reply || !reply.usage) {
489 return stopKeepWarm($, 'the ping got no answer, so the cache may already be cold')
490 }
491 const u = reply.usage
492 S.pings += 1
493 S.lastPingAt = now
494 S.pingUsd += requestCost(u, S.model, ttl)
495 // A ping that writes instead of reading did not hit the cache: stop paying for it
496 if ((u.cache_creation_input_tokens || 0) > 0.1 * Math.max(1, u.cache_read_input_tokens || 0)) {
497 return stopKeepWarm($, `the ping wrote ${tokens(u.cache_creation_input_tokens)} tokens instead of reading the cache`)
498 }
499 S.lastActivity = now
500}
501
502function warnStep($) {
503 if (!settings.alerts) return
504 for (const hb of fleet) {
505 if (hb.stale || hb.keepWarm || (hb.ctx || 0) < settings.bigTokens) continue
506 const st = cacheState(hb)
507 if (st.kind !== 'cooling') continue
508 const key = 'cool:' + hb.lastActivity
509 if (alerted.get(hb.id) === key) continue
510 alerted.set(hb.id, key)
511 const cost = money(rewriteCost(hb.ctx, hb.model, hb.ttlMin))
512 const who = hb.self ? 'This session' : `"${mask(hb.label)}"`
513 const how = hb.self ? `type /${names.keepwarm} or press 1` : `open /${names.board} to keep it warm`
514 $.ui.toast(`${who}: ${tokens(hb.ctx)}-token cache goes cold in ${minutes(st.left)}. Rewriting it costs about ${cost}. ${how}.`, { timeoutMs: 15000 })
515 }
516 // Another session started waiting on a permission or a question
517 for (const hb of fleet) {
518 if (hb.self || hb.stale) continue
519 const prev = alerted.get('state:' + hb.id)
520 alerted.set('state:' + hb.id, hb.state)
521 if (hb.state === 'waiting' && prev && prev !== 'waiting') {
522 $.ui.toast(`"${mask(hb.label)}" is waiting on you${hb.waitingFor ? ': ' + hb.waitingFor : ''}`, { timeoutMs: 10000 })
523 }
524 }
525}
526
527async function tick($) {
528 now = await $.clock.now()
529 await readRecording($)
530 await applyRemoteCommands($)
531 await keepWarmStep($)
532 await heartbeat($, true)
533 await readFleet($)
534 warnStep($)
535 $.ui.invalidate('ui.render')
536}
537
538async function loadSettings($) {
539 const saved = await $.store.get('settings')
540 if (saved && typeof saved === 'object') Object.assign(settings, saved)
541}
542
543function statusText() {
544 const st = cacheState(myHeartbeat())
545 const ttl = ttlMin()
546 const lines = []
547 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}).`)
548 if (st.kind === 'unknown') lines.push('No request yet this session, so the cache state is unknown.')
549 if (st.kind === 'warm' || st.kind === 'cooling') lines.push(`Cache is warm for about ${minutes(st.left)} more.`)
550 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))}.`)
551 if (S.keepWarm) lines.push(`Keep warm is on until ${clock(S.keepWarmUntil)}: ${S.pings} ping(s), ${money(S.pingUsd)} so far.`)
552 if (S.rateLimits.length) lines.push(`Plan limits used: ${limitsText(S.rateLimits)}.`)
553 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))}).`)
554 lines.push(`Cold-send guard: ${settings.guard ? 'on' : 'off'} for contexts over ${tokens(settings.bigTokens)} tokens. Alerts: ${settings.alerts ? 'on' : 'off'}.`)
555 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}.`)
556 return lines.join('\n')
557}
558
559function parseTokens(text) {
560 const m = String(text || '').trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([km]?)$/)
561 if (!m) return null
562 return Math.round(Number(m[1]) * (m[2] === 'm' ? 1e6 : m[2] === 'k' ? 1e3 : 1))
563}
564
565export function register(on) {
566 on('session.start', async ($, e, next) => {
567 now = await $.clock.now()
568 home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
569 S.id = await $.session.id()
570 S.cwd = await $.session.cwd()
571 S.label = basename(S.cwd)
572 S.model = await $.session.model()
573 try {
574 applyUsage(await $.session.usage())
575 } catch {
576 // usage unavailable: the first measurement fills it in
577 }
578 await loadSettings($)
579 await readRecording($)
580 names.cache = (await registerCommand($, 'cache', 'Cache Keeper status and settings', '[ttl 5|60|auto] [guard on|off] [big 150k] [alerts on|off]')) || names.cache
581 names.keepwarm = (await registerCommand($, 'keepwarm', 'Keep this session\'s prompt cache warm (default 4 hours), or /keepwarm off', '[hours|off]', true)) || names.keepwarm
582 names.board = (await registerCommand($, 'board', 'Every local Claude Code session: state, context, cache, cost', '', true)) || names.board
583 names.handoff = (await registerCommand($, 'handoff', 'Session handoff, then clear this chat and continue with it (/handoff continue)', '[continue]')) || names.handoff
584 $.clock.every(HEARTBEAT_EVERY, () => tick($).catch(() => {}))
585 await heartbeat($, true)
586 return next(e)
587 })
588
589 // /clear, /resume and /branch start from an unknown cache. The process goes
590 // on under a new session id, and no session.start fires for it.
591 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
592 S.lastActivity = 0
593 S.keepWarm = false
594 H.armed = false
595 receipt = null
596 try {
597 S.id = (await $.session.id()) || S.id
598 } catch {
599 // keep the old id
600 }
601 S.label = basename(S.cwd)
602 return next(e)
603 })
604
605 on('session.end', async ($, e, next) => {
606 if (S.id) await $.store.delete('hb:' + S.id)
607 return next(e)
608 })
609
610 // The first prompt names the session on the board
611 on('prompt.submit', async ($, e, next) => {
612 now = await $.clock.now()
613 if (S.label === basename(S.cwd) && e.origin && e.origin.kind === 'composer' && e.text) {
614 S.label = basename(S.cwd) + ' · ' + clip(e.text, 40)
615 }
616 // A new message in this chat makes a waiting handoff out of date (its file stays)
617 if (H.text && !H.continuing && e.origin && e.origin.kind === 'composer' && !String(e.text || '').trim().startsWith('/')) {
618 H.text = ''
619 H.path = ''
620 $.ui.invalidate('ui.render')
621 }
622 const ttl = ttlMin()
623 const left = msLeft(S.lastActivity, ttl)
624 const isCold = left !== null && left <= 0
625 const fromUser = e.origin && (e.origin.kind === 'composer' || e.origin.kind === 'bridge')
626 if (!settings.guard || !fromUser || !isCold || S.ctx < settings.bigTokens || e.turnId) return next(e)
627 const cost = money(rewriteCost(S.ctx, S.model, ttl))
628 let answer = 'Send anyway'
629 try {
630 answer = await $.ui.ask(
631 `Cache went cold ${minutes(-left)} ago. Sending now rewrites ${tokens(S.ctx)} tokens of context (about ${cost}). What should happen?`,
632 ['Send anyway', 'Compact first, then send', 'Cancel'],
633 )
634 } catch {
635 // nobody to ask (a -p run, or the dialog was dismissed): send as typed
636 return next(e)
637 }
638 if (answer === 'Compact first, then send') {
639 // Compaction is refused while this prompt is still being submitted, so hold
640 // the prompt, compact once the session is idle, then send it again.
641 const resend = { text: e.text, asUser: true }
642 if (e.attachments) resend.attachments = e.attachments
643 $.clock.after(50, () => compactThenSend($, resend).catch(() => {}))
644 $.ui.toast('Compacting first. Your message goes out right after.', { timeoutMs: 8000 })
645 return { drop: 'Held by cache-keeper: compacting before a cold send' }
646 }
647 if (answer === 'Send anyway') return next(e)
648 $.ui.toast('Not sent. A fresh session with a short handoff avoids the rewrite entirely.', { timeoutMs: 8000 })
649 return { drop: 'Cancelled by cache-keeper before a cold cache rewrite' }
650 })
651
652 on('turn.start', async ($, e, next) => {
653 now = await $.clock.now()
654 S.working = true
655 if (e.turnId) S.turnId = e.turnId
656 await heartbeat($, true)
657 return next(e)
658 })
659
660 // Each main-loop request: did it read the cache, or rewrite it?
661 on('turn.step', async function* ($, e, next) {
662 if (!e.agentId && e.effort !== undefined && String(e.effort) !== S.effort) {
663 S.effort = String(e.effort)
664 $.ui.invalidate('ui.render')
665 }
666 const startedAt = await $.clock.now()
667 const result = yield* next(e)
668 if (e.agentId || !result || !result.usage) return result
669 now = await $.clock.now()
670 const u = result.usage
671 const total = totalInput(u)
672 const written = u.cache_creation_input_tokens || 0
673 const gap = S.lastActivity ? startedAt - S.lastActivity : 0
674 S.model = u.model || S.model
675 const afterCompact = justCompacted
676 justCompacted = false
677 if (afterCompact) {
678 // the first request after a compaction writes the new, shorter context: expected
679 } else if (S.lastActivity && total > 30000 && written / total > 0.5) {
680 // A rewrite after 5-60 idle minutes means this session runs on the 5-minute TTL
681 if (!settings.ttlMin && gap > 5.5 * MIN && gap < S.ttlMin * MIN) {
682 S.ttlMin = 5
683 S.ttlSource = 'measured'
684 }
685 S.coldRestarts.push({ at: now, tokens: written, usd: rewriteCost(written, S.model, ttlMin()), gapMin: Math.round(gap / MIN) })
686 } else if (S.lastActivity && total > 30000 && gap > 5.5 * MIN && cachedShare(u) > 0.8) {
687 // A hit after more than 5 idle minutes proves the 1-hour TTL
688 if (!settings.ttlMin) {
689 S.ttlMin = 60
690 S.ttlSource = 'measured'
691 }
692 }
693 // S.ctx comes from session.measure / $.session.usage(), the status line's figures
694 S.lastActivity = now
695 return result
696 })
697
698 on('turn.complete', async ($, e, next) => {
699 const r = await next(e)
700 if (e.agentId) return r
701 now = await $.clock.now()
702 S.working = false
703 S.waitingSince = 0
704 S.tool = ''
705 S.lastTurnEnd = now
706 try {
707 applyUsage(await $.session.usage())
708 } catch {
709 // usage unavailable: keep the per-request figures
710 }
711 if (H.armed && e.turnId !== H.notTurn) await captureHandoff($, e)
712 await heartbeat($, true)
713 $.ui.invalidate('ui.render')
714 return r
715 })
716
717 // Every compaction of this conversation without instructions of its own (the
718 // automatic one, a bare /compact) gets the keep-list. A plugin's own compaction
719 // keeps whatever that plugin said.
720 on('session.compact', async ($, e, next) => {
721 const isMain = !e.agentId
722 const isBare = !String(e.instructions || '').trim()
723 const add = isMain && isBare && e.trigger !== 'plugin'
724 const r = await next(add ? { ...e, instructions: KEEP_LIST } : e)
725 if (!isMain || e.trigger === 'precompute' || (r && r.skip)) return r
726 justCompacted = true
727 now = await $.clock.now()
728 noteCompact(r)
729 $.ui.invalidate('ui.render')
730 return r
731 })
732
733 // Fires whenever the status line's figures move: redraw the band with them
734 on('session.measure', async ($, e, next) => {
735 applyUsage(e)
736 $.ui.invalidate('ui.render')
737 return next(e)
738 })
739
740 // Waiting on the person: a permission prompt or a question
741 on('tool.check', async ($, e, next) => {
742 const d = await next(e)
743 if (d && d.decision === 'ask' && !S.waitingSince) {
744 now = await $.clock.now()
745 S.waitingSince = now
746 S.waitingFor = 'permission for ' + e.tool
747 }
748 return d
749 })
750
751 on('tool.call', async ($, e, next) => {
752 if (!e.agentId) S.tool = e.tool
753 // Claude calling the handoff skill itself: this turn's answer is the handoff
754 if (!e.agentId && e.tool === 'Skill' && HANDOFF_SKILL.test(String(e.skill || ''))) armHandoff(true)
755 if (e.tool === 'AskUserQuestion') {
756 now = await $.clock.now()
757 S.waitingSince = now
758 S.waitingFor = 'a question'
759 }
760 try {
761 return await next(e)
762 } finally {
763 if (S.waitingSince) {
764 S.waitingSince = 0
765 S.waitingFor = ''
766 }
767 }
768 })
769
770 // /session-handoff from anywhere (typed, the band, /handoff): watch for its answer
771 on('command.run', async ($, e, next) => {
772 if (HANDOFF_SKILL.test(String(e.command || ''))) armHandoff(false)
773 return next(e)
774 })
775
776 // /handoff runs the handoff and then asks to clear and continue (the band's
777 // buttons draw only in the terminal); /handoff continue does the second half.
778 // Both run off the hook: a command started inside a hook the session is
779 // waiting on is refused.
780 on('command.run', { command: ['handoff', 'ck-handoff'] }, async ($, e) => {
781 now = await $.clock.now()
782 const arg = String(e.args || '').trim().toLowerCase()
783 if (arg === 'continue' || arg === 'go') {
784 if (!H.text) return { text: `No handoff ready yet. Type /${names.handoff} to make one.` }
785 $.clock.after(50, () => clearAndContinue($).catch(() => {}))
786 return { text: 'Clearing this chat and continuing with the handoff.' }
787 }
788 H.askAfter = true
789 $.clock.after(50, () => runHandoff($).catch(() => {}))
790 return { text: 'Running the session handoff. When it finishes, you can clear this chat and continue with it.' }
791 })
792
793 on('command.run', { command: ['cache', 'ck-cache'] }, async ($, e) => {
794 now = await $.clock.now()
795 const [key, value] = String(e.args || '').trim().toLowerCase().split(/\s+/)
796 if (key === 'ttl') {
797 settings.ttlMin = value === '5' ? 5 : value === '60' ? 60 : 0
798 } else if (key === 'guard' || key === 'alerts') {
799 settings[key] = value !== 'off'
800 } else if (key === 'big') {
801 const n = parseTokens(value)
802 if (n) settings.bigTokens = n
803 }
804 if (key) {
805 await $.store.set('settings', settings)
806 $.ui.invalidate('ui.render')
807 }
808 return { text: statusText() }
809 })
810
811 on('command.run', { command: ['keepwarm', 'ck-keepwarm'] }, async ($, e) => {
812 now = await $.clock.now()
813 const arg = String(e.args || '').trim().toLowerCase()
814 if (arg === 'off' || (arg === '' && S.keepWarm)) {
815 stopKeepWarm($, 'turned off')
816 } else {
817 const hours = Number(arg) > 0 ? Math.min(24, Number(arg)) : settings.keepWarmHours
818 startKeepWarm($, hours)
819 }
820 await heartbeat($, true)
821 $.ui.invalidate('ui.render')
822 return {}
823 })
824
825 on('command.run', { command: ['board', 'ck-board'] }, async ($, e) => {
826 now = await $.clock.now()
827 await heartbeat($, true)
828 await readFleet($)
829 const surface = await $.session.surface()
830 if (!surface) return { text: boardText() }
831 paneOpen = true
832 await $.ui.open({ id: PANE, title: 'Sessions', focus: true, closeOnEscape: true })
833 return {}
834 })
835
836 on('ui.close', async ($, e, next) => {
837 if (e.id === PANE) paneOpen = false
838 return next(e)
839 })
840
841 // The band above the prompt: model, context and this session's cache at a
842 // glance. It replaces the status line, so it carries that line's figures too.
843 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
844 const below = await next(e)
845 if (e.props && e.props.hasSurvey) return below
846 if (!S.model && !S.lastActivity && !S.keepWarm) return below
847 now = await $.clock.now()
848 if (e.surface === 'desktop') return desktopAlert($, e, below)
849 const { Box, Text, Button } = $.ui.resolve(e)
850 const hb = myHeartbeat()
851 const st = cacheState(hb)
852 const ttl = ttlMin()
853 const big = S.ctx >= settings.bigTokens
854 const rewrite = money(rewriteCost(S.ctx, S.model, ttl))
855 // [name, text, Text props], joined by │
856 const segs = []
857 const done = liveReceipt()
858 if (done) segs.push(['receipt', receiptText(done), { color: 'green', bold: true }])
859 if (S.model) segs.push(['model', modelLabel(S.model) + (S.effort ? ` [${S.effort}]` : ''), {}])
860 if (st.kind === 'kept') segs.push(['cache', `◆ kept warm · ${S.pings} ping${S.pings === 1 ? '' : 's'} ${money(S.pingUsd)} · until ${clock(S.keepWarmUntil)}`, { color: 'cyan' }])
861 else if (st.kind === 'warm') segs.push(['cache', `● cache warm ${minutes(st.left)}`, { color: 'green' }])
862 else if (st.kind === 'cooling') segs.push(['cache', `◐ cache cools in ${minutes(st.left)}`, { color: 'yellow', bold: true }])
863 else if (st.kind === 'cold') segs.push(['cache', `○ cache cold ${minutes(-st.left)}`, big ? { color: 'red', bold: true } : { dimColor: true }])
864 if (S.ctx) {
865 segs.push(['ctx', ctxText(true), { dimColor: true }])
866 if (st.kind === 'cold' && big) segs.push(['rewrite', `next send rewrites it ≈ ${rewrite}`, { color: 'red' }])
867 else segs.push(['rewrite', `rewrite ≈ ${rewrite}`, { dimColor: true }])
868 }
869 if (S.rateLimits.length) segs.push(['limits', limitsText(S.rateLimits), limitTone(S.rateLimits, {})])
870 if (S.costUsd) segs.push(['session', `session ${money(S.costUsd)}`, { dimColor: true }])
871 if (S.coldRestarts.length) segs.push(['cold', `${S.coldRestarts.length} cold restart${S.coldRestarts.length === 1 ? '' : 's'} ${money(S.coldRestarts.reduce((a, c) => a + c.usd, 0))}`, { dimColor: true }])
872 // Too wide for the row beside the buttons: drop the bar, keep the numbers
873 const cols = (e.props && e.props.bodyColumns) || 0
874 const reserve = 12 + (H.text || H.continuing ? 22 : 0) + (st.kind === 'kept' || (st.kind === 'cooling' && big) ? 13 : 0)
875 const width = segs.reduce((a, s) => a + s[1].length, 0) + 3 * (segs.length - 1) + reserve
876 if (cols && width > cols) {
877 const ctx = segs.find((s) => s[0] === 'ctx')
878 if (ctx) ctx[1] = ctxText(false)
879 }
880 const parts = segs.map(([, text, props], i) => Text({ ...props, children: [(i ? ' │ ' : '') + text] }))
881 const row = [Box({ flexDirection: 'row', children: parts })]
882 if (done) row.push(dismissReceipt($, Button, 'ok'))
883 if (st.kind === 'cooling' && big) {
884 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') } }))
885 } else if (st.kind === 'kept') {
886 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') } }))
887 }
888 const left = Box({ flexDirection: 'row', columnGap: 2, children: row })
889 // Right edge: one click runs /session-handoff (h while the band has the focus;
890 // a letter never fires from the prompt box, unlike a digit). Once a handoff
891 // is in, c clears this chat and sends it as the fresh chat's first prompt.
892 const busy = handoffPending || H.armed
893 const handoff = Button({ key: 'handoff', label: handoffPending ? 'handoff queued' : H.armed ? 'handoff running' : 'handoff', hotkey: 'h', plain: true, dimColor: busy, onPress: () => runHandoff($) })
894 let right = handoff
895 if (H.text || H.continuing) {
896 const go = Button({ key: 'continue', label: H.continuing ? 'clearing' : 'clear and continue', hotkey: 'c', plain: true, onPress: () => clearAndContinue($) })
897 right = Box({ flexDirection: 'row', columnGap: 2, children: [go, handoff] })
898 }
899 const mine = Box({ flexDirection: 'row', justifyContent: 'space-between', width: '100%', columnGap: 2, children: [left, right] })
900 return Box({ flexDirection: 'column', children: below ? [mine, below] : [mine] })
901 })
902
903 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
904 if (e.requestId !== PANE) return next(e)
905 return drawBoard($, e)
906 })
907
908 // The footer carries a short label, only when there's something to act on.
909 // On desktop the alert band already says it, so the footer stays the app's.
910 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
911 if (e.surface === 'desktop') return next(e)
912 const label = footerLabel()
913 if (!label) return next(e)
914 const modes = Array.isArray(e.props && e.props.modes) ? e.props.modes : []
915 return next({ ...e, props: { ...e.props, modes: [...modes, label] } })
916 })
917}
918
919// The desktop band. Token Optimizer's card already shows model, context, cache
920// timer and plan limits there, so this draws one line only when there's
921// something to act on: a big cache cooling or cold, keep-warm running, or a
922// handoff in flight. One Text, so the app can't wrap it piece by piece.
923function desktopAlert($, e, below) {
924 const st = cacheState(myHeartbeat())
925 const big = S.ctx >= settings.bigTokens
926 const rewrite = money(rewriteCost(S.ctx, S.model, ttlMin()))
927 const handoffBusy = handoffPending || H.armed || H.text || H.continuing
928 const done = liveReceipt()
929 let line = ''
930 let style = {}
931 if (done) {
932 line = receiptText(done)
933 style = { color: 'green', bold: true }
934 } else if (st.kind === 'kept') {
935 line = `◆ Cache kept warm until ${clock(S.keepWarmUntil)} · ${S.pings} ping${S.pings === 1 ? '' : 's'} ${money(S.pingUsd)}`
936 style = { color: 'cyan' }
937 } else if (st.kind === 'cooling' && big) {
938 line = `◐ Cache cools in ${minutes(st.left)} · then the next send costs ≈ ${rewrite}`
939 style = { color: 'yellow', bold: true }
940 } else if (st.kind === 'cold' && big) {
941 line = `○ Cache cold · the next send rewrites it ≈ ${rewrite}`
942 style = { color: 'red', bold: true }
943 }
944 if (!line && !handoffBusy) return below
945 const { Box, Text, Button } = $.ui.resolve(e)
946 const left = []
947 if (line) left.push(Text({ ...style, children: [line] }))
948 if (done) left.push(dismissReceipt($, Button, 'OK'))
949 else if (st.kind === 'cooling' && big) {
950 left.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') } }))
951 } else if (st.kind === 'kept') {
952 left.push(Button({ key: 'keepwarm', label: 'Stop', hotkey: '1', plain: true, onPress: async () => { now = await $.clock.now(); stopKeepWarm($, 'turned off'); await heartbeat($, true); $.ui.invalidate('ui.render') } }))
953 }
954 const right = []
955 if (H.text || H.continuing) {
956 right.push(Button({ key: 'continue', label: H.continuing ? 'Clearing' : 'Clear and continue', hotkey: 'c', plain: true, onPress: () => clearAndContinue($) }))
957 } else if (handoffPending || H.armed) {
958 right.push(Text({ dimColor: true, children: [handoffPending ? 'Handoff queued' : 'Handoff running'] }))
959 }
960 // paddingTop: space from Token Optimizer's card, which sits right above
961 const mine = Box({ flexDirection: 'row', justifyContent: 'space-between', width: '100%', columnGap: 2, paddingTop: 'sm', children: [Box({ flexDirection: 'row', columnGap: 2, children: left }), Box({ flexDirection: 'row', columnGap: 2, children: right })] })
962 return Box({ flexDirection: 'column', children: below ? [mine, below] : [mine] })
963}
964
965function dismissReceipt($, Button, label) {
966 return Button({ key: 'receipt-ok', label, hotkey: 'o', plain: true, dimColor: true, onPress: () => { receipt = null; $.ui.invalidate('ui.render') } })
967}
968
969function footerLabel() {
970 const parts = []
971 const st = cacheState(myHeartbeat())
972 const big = S.ctx >= settings.bigTokens
973 if (st.kind === 'kept') parts.push('cache kept warm')
974 else if (st.kind === 'cooling' && big) parts.push(`cache cools in ${minutes(st.left)} · /keepwarm`)
975 else if (st.kind === 'cold' && big) parts.push(`cache cold · rewrite ≈ ${money(rewriteCost(S.ctx, S.model, ttlMin()))}`)
976 const high = S.rateLimits.filter((l) => (l.percentUsed || 0) >= 80)
977 if (high.length) parts.push(limitsText(high))
978 if (H.text) parts.push(`handoff ready · /${names.handoff} continue`)
979 return parts.join(' · ')
980}
981
982function sortFleet(list) {
983 const rank = { waiting: 0, working: 1, idle: 2 }
984 return [...list].sort((a, b) => {
985 const r = (rank[a.state] ?? 3) - (rank[b.state] ?? 3)
986 if (r !== 0) return r
987 const la = msLeft(a.lastActivity, a.ttlMin) ?? Infinity
988 const lb = msLeft(b.lastActivity, b.ttlMin) ?? Infinity
989 return la - lb
990 })
991}
992
993function cacheCell(hb) {
994 const st = cacheState(hb)
995 if (st.kind === 'kept') return ['kept warm', 'cyan']
996 if (st.kind === 'warm') return ['warm ' + minutes(st.left), 'green']
997 if (st.kind === 'cooling') return ['cools in ' + minutes(st.left), 'yellow']
998 if (st.kind === 'cold') return ['cold ' + minutes(-st.left), (hb.ctx || 0) >= settings.bigTokens ? 'red' : 'gray']
999 return ['–', 'gray']
1000}
1001
1002function stateCell(hb) {
1003 if (hb.stale) return ['? silent', 'gray']
1004 if (hb.state === 'waiting') return ['⏳ waiting', 'magenta']
1005 if (hb.state === 'working') return ['● working', 'cyan']
1006 return ['○ idle', 'gray']
1007}
1008
1009// Text props for a colour name; 'gray' draws dim, which every theme has
1010function tone(color, extra = {}) {
1011 return color === 'gray' ? { ...extra, dimColor: true } : { ...extra, color }
1012}
1013
1014function boardText() {
1015 const rows = sortFleet(fleet).map((hb) => {
1016 const [state] = stateCell(hb)
1017 const [cache] = cacheCell(hb)
1018 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)' : ''}`
1019 })
1020 return ['Sessions on this machine:', ...rows].join('\n')
1021}
1022
1023function drawBoard($, e) {
1024 const { Box, Text, Button } = $.ui.resolve(e)
1025 const width = Math.max(60, (e.props && e.props.bodyColumns) || 100)
1026 const labelWidth = Math.max(16, Math.min(44, width - 62))
1027 const list = sortFleet(fleet)
1028 const header = Text({ dimColor: true, children: [`${pad('STATE', 11)} ${pad('SESSION', labelWidth + 1)} ${pad('CONTEXT', 8)} ${pad('CACHE', 14)} ${pad('COST', 7)} AGENTS`] })
1029 const rows = list.map((hb, i) => {
1030 const [state, stateColor] = stateCell(hb)
1031 const [cache, cacheColor] = cacheCell(hb)
1032 const label = clip(mask(hb.label || hb.id), labelWidth) + (hb.self ? ' ◂' : '')
1033 const ctx = tokens(hb.ctx) + (hb.window ? ' ' + Math.round((100 * (hb.ctx || 0)) / hb.window) + '%' : '')
1034 const cells = [
1035 Text(tone(stateColor, { children: [pad(state, 11) + ' '] })),
1036 Text({ bold: !!hb.self, children: [pad(label, labelWidth + 1) + ' '] }),
1037 Text({ children: [pad(ctx, 8) + ' '] }),
1038 Text(tone(cacheColor, { children: [pad(cache, 14) + ' '] })),
1039 Text({ children: [pad(money(hb.costUsd), 7) + ' '] }),
1040 Text({ dimColor: !hb.agentsRunning, children: [String(hb.agentsRunning || 0) + ' '] }),
1041 ]
1042 const big = (hb.ctx || 0) >= settings.bigTokens
1043 if (big && !hb.stale && i < 9) {
1044 cells.push(
1045 Button({
1046 key: 'kw-' + hb.id,
1047 label: hb.keepWarm ? 'stop warm' : 'keep warm',
1048 hotkey: String(i + 1),
1049 plain: true,
1050 dimColor: !hb.keepWarm && cacheState(hb).kind !== 'cooling',
1051 onPress: async () => {
1052 now = await $.clock.now()
1053 if (hb.self) {
1054 if (S.keepWarm) stopKeepWarm($, 'turned off')
1055 else startKeepWarm($, settings.keepWarmHours)
1056 await heartbeat($, true)
1057 } else {
1058 await $.store.set('cmd:' + hb.id, { keepWarm: !hb.keepWarm, at: now })
1059 $.ui.toast(`Sent to "${mask(hb.label)}". It applies within 30 seconds.`)
1060 }
1061 await readFleet($)
1062 $.ui.invalidate('ui.render')
1063 },
1064 }),
1065 )
1066 }
1067 const line = Box({ flexDirection: 'row', children: cells })
1068 const detail = []
1069 if (hb.state === 'waiting' && hb.waitingFor) detail.push(`needs you: ${hb.waitingFor}`)
1070 else if (hb.state === 'working' && hb.tool) detail.push(`running ${hb.tool}`)
1071 if (hb.coldCount) detail.push(`${hb.coldCount} cold restart${hb.coldCount === 1 ? '' : 's'} ${money(hb.coldUsd)}`)
1072 if (hb.keepWarm) detail.push(`warm until ${clock(hb.keepWarmUntil)}, ${hb.pings} ping(s) ${money(hb.pingUsd)}`)
1073 if (detail.length === 0) return line
1074 return Box({ flexDirection: 'column', children: [line, Text({ dimColor: true, children: [' └ ' + detail.join(' · ')] })] })
1075 })
1076 const totalCost = list.reduce((a, hb) => a + (hb.costUsd || 0), 0)
1077 const totalCold = list.reduce((a, hb) => a + (hb.coldUsd || 0), 0)
1078 const waiting = list.filter((hb) => hb.state === 'waiting').length
1079 const footer = Text({
1080 dimColor: true,
1081 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`],
1082 })
1083 return Box({ flexDirection: 'column', children: [header, ...rows, Text({ children: [' '] }), footer] })
1084}
1085hooks/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