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

A compact prompt-cache status band for Claude Code, with a cold-send guard, keep-warm, a board of all local chats, and handoffs to a fresh chat.
This is a fork of Cache Keeper by Nate Herk (nateherkai/claude-code-mods), MIT licensed. The cache logic, board, guard and handoff flow are his work. See Changes from Cache Keeper for what this fork adds.
<img src="screenshots/band.png" alt="session-band above the prompt" width="686">
The band above the prompt: cache warm for 60 minutes, 342k tokens in context, a cold rewrite would cost about $2.73, the session cost $13 so far, and the 5-hour and weekly plan limits.
Every request re-reads the whole chat. From the prompt cache that costs a tenth of the normal input price. Once the cache expires (5 minutes or 1 hour idle, depending on your plan), the next message writes the whole chat into the cache again at up to 2x the input price. On a large chat that one message can cost a few dollars.
claude plugin test in any folder answers "no hooks module to load" when mods can load./plugin marketplace add IT-BAER/claude-forge
/plugin install session-band@claude-forge
Start a new session afterwards.
● c 60m │ ctx 342k │ rwc $2.73 │ sc $13 │ 5h 84% w 24%
| Part | Meaning |
|---|---|
c 60m | Prompt cache state and minutes until it expires. Yellow 5 minutes before expiry, red once cold. While keep-warm runs: ◆ c kept 7:12pm, the time it stops. |
ctx | Tokens in context. |
rwc | Rewrite cost: what writing the context into the cache again would cost if it expires. |
sc | Session cost so far, at API list prices. |
cr | Cold restarts: how many sends rewrote an expired cache this session, and what they cost. Shown only after the first one. |
5h, w, fw | 5-hour, weekly and per-model weekly (fw for Fable, the model's initial) plan limits used. Yellow at 80 %, red at 95 %. The per-model windows come from the plan usage endpoint (/api/oauth/usage, asked at most every 5 minutes), because the mod API does not pass them. |
Hover c, ctx, rwc, sc or cr for a one-line explanation above the band. Each starts with what the label stands for (rwc = rewrite cost: ...), and c names the state in full (kept, warm, cooling, cold). Hover the plan limits for what each window is and when it resets: 5h (5-hour window) resets 1:50pm (in 2h15m) · w (weekly, all models) resets Fri 9:00am (in 3d21h). On a subscription the prices show up as usage against your limits, not as a bill.
/keepwarm [hours|off] keeps this chat's cache warm, 4 hours by default, with a small ping shortly before it would expire./board shows every local chat in one pane: waiting on you first, then working, then the ones whose cache cools soonest./handoff (or the handoff button) runs the bundled session-handoff skill, then clears the chat and starts the fresh one from the handoff./cache shows status and settings: ttl 5|60|auto, guard on|off, big 150k, alerts [on|off]./cache alerts toggles them.c, rwc, sc, w) so the band fits next to the prompt, session cost before the plan limits.session-handoff.md, a restart-packet.md (also with a suffix, like session-handoff-pve.md) or a file under handoffs/, the band offers to clear and continue from that file as soon as it is written. Any header format works; only a header marked status: consumed (or another non-active status) is skipped. If a handoff turn ends on a short answer (a Stop hook added a tail), the last handoff file written in the session is used instead. The fresh chat gets a short "read this file" prompt instead of the whole text.session-band. Data lives in ~/.claude/mods-data/session-band/.cache-status. To switch, run /plugin uninstall cache-status@claude-forge, then install session-band@claude-forge and restart each chat. Nothing reads ~/.claude/mods-data/cache-status/ any more, so you can delete that folder once no chat runs the old mod.Everything stays on your machine, under ~/.claude/mods-data/. The mod makes no network calls of its own. Keep-warm pings are small model requests, sent only while /keepwarm is on.
MIT. See LICENSE. Original work copyright Nate Herk.
hooks/register.mjs 1226 lines1// Session Band (fork of Cache Keeper by Nate Herk): watches this session's prompt cache, keeps big caches warm on
2// request, refuses a cold send without asking first, and shows every local
3// session on one board (/board).
4//
5// Why: each request re-reads the whole context. From the cache that costs about
6// a tenth of normal input. Once the cache expires (1 hour idle on a subscription,
7// 5 minutes on the default API TTL), the next message writes the whole context
8// again at 1.25x to 2x input. A cache read also restarts the timer, so a tiny
9// ping before expiry costs a fraction of a rewrite.
10
11import { rewriteCost, requestCost, totalInput, cachedShare, priceFor } from './pricing.mjs'
12import { tokens, usd, minutes, clock, clip, basename, pad } from './fmt.mjs'
13import { makeMasker } from './privacy.mjs'
14
15const MIN = 60000
16const HEARTBEAT_EVERY = 30000
17const OUTSIDE_EVERY = 2000 // command files and handoff state for the VS Code extension
18const PANE = 'cache-board'
19
20// This session
21const S = {
22 id: '',
23 cwd: '',
24 label: '',
25 model: '',
26 lastActivity: 0, // last main-loop request or keep-warm ping that touched the cache
27 ctx: 0,
28 window: 0,
29 costUsd: 0,
30 ttlMin: 60,
31 ttlSource: 'default',
32 coldRestarts: [],
33 working: false,
34 turnId: '', // the main loop's running (or last) turn
35 waitingSince: 0,
36 waitingFor: '',
37 tool: '',
38 lastTurnEnd: 0,
39 keepWarm: false,
40 keepWarmUntil: 0,
41 pings: 0,
42 pingUsd: 0,
43 lastPingAt: 0,
44 warnedFor: 0,
45 agentsRunning: 0,
46 rateLimits: [],
47}
48
49const settings = { bigTokens: 150000, guard: true, ttlMin: 0, alerts: false, keepWarmHours: 4 }
50const names = { cache: 'cache', keepwarm: 'keepwarm', board: 'board', handoff: 'handoff' }
51
52let now = 0
53let home = ''
54let lastHeartbeat = 0
55let rec = { on: false, strict: false }
56let mask = (s) => s
57let fleet = [] // heartbeats of every session, this one included
58const alerted = new Map() // sessionId -> state or warning key already announced
59let paneOpen = false
60let justCompacted = false
61let modelLimits = [] // per-model weekly windows (Fable, ...) from the usage endpoint
62let usageAskedAt = -Infinity
63let handoffPending = false
64let lastLiveHandoff = '' // handoff state in the last live file
65let lastCommandAt = 0 // newest command file already taken
66let outsideBusy = false
67let endedId = '' // the chat session.end closed: never written as live again
68
69// The handoff flow. When /session-handoff runs (the band's button, /handoff, a
70// typed command, or Claude calling the skill), the answer of the turn it starts
71// is captured and saved to a file. "clear and continue" then runs /clear and
72// sends the handoff as the fresh chat's first prompt.
73const HANDOFF_SKILL = /(^|:)session-handoff$/
74const H = {
75 armed: false,
76 notTurn: '', // a turn already running when the handoff was queued: not the handoff's
77 askAfter: false, // /handoff asks "clear and continue?" once the handoff is in
78 text: '',
79 path: '',
80 file: '', // a handoff file Claude wrote with Write/Edit this turn (no button, no command)
81 lastFile: '', // the last handoff file written this session: the fallback when a handoff turn ends on a short answer
82 fromFile: false, // H.text came from that file: the fresh chat reads it by path
83 continuing: false,
84 resuming: false, // the last prompt was a resume line: its skill call consumes, not creates
85 announce: false, // a handoff file caught mid-turn: its toast waits for the turn end
86}
87// Clear and continue is offered only once Claude has stopped working
88const ready = () => !!H.text && !S.working
89const RESUME_LINE = /^\s*Continue from session handoff:/i
90
91// session-handoff.md, restart-packet.md, suffixed names (session-handoff-pve.md), or any .md under /handoffs/
92const HANDOFF_FILE = /[^"'\s]*(?:(?:session-handoff|restart-packet)[^"'\s/]*\.md|\/handoffs\/[^"'\s]*\.md)/
93
94// A shell path as an absolute one: Git Bash /d/x is D:/x, ~ is home, relative is under dir
95function shellPath(p, dir) {
96 p = p.replace(/\\/g, '/').replace(/^\/([a-z])\//i, (_, d) => d.toUpperCase() + ':/')
97 if (p.startsWith('~/')) return home.replace(/\\/g, '/') + p.slice(1)
98 if (/^[a-z]:\//i.test(p) || p.startsWith('/')) return p
99 return `${dir.replace(/\\/g, '/').replace(/\/$/, '')}/${p.replace(/^\.\//, '')}`
100}
101
102const SHELL_WRITE = />>?|\btee\b|\bsed\s+-i|\b(?:Set|Add)-Content\b|\bOut-File\b/i
103const SHELL_CD = /^\s*(?:cd|Set-Location)\s+['"]?([^'"\s]+)['"]?\s*$/i
104
105// The handoff file a Bash or PowerShell command writes: a path after >, tee, sed -i,
106// Set-Content, Add-Content or Out-File in one command step. shortcut: no quote-aware splitting.
107function shellHandoffWrite(command) {
108 let dir = S.cwd
109 for (const step of command.split(/&&|\|\|?|;|\n/)) {
110 const cd = SHELL_CD.exec(step)
111 if (cd) {
112 dir = shellPath(cd[1], dir)
113 continue
114 }
115 const w = SHELL_WRITE.exec(step)
116 const m = w && HANDOFF_FILE.exec(step.slice(w.index + w[0].length).replace(/\\/g, '/'))
117 if (m) return shellPath(m[0], dir)
118 }
119 return ''
120}
121
122// Notes a write to a handoff file; true when the write was one
123function noteHandoffFile(e) {
124 if (e.tool === 'Bash' || e.tool === 'PowerShell') {
125 const path = shellHandoffWrite(String((e.input && e.input.command) || e.command || ''))
126 if (path) H.file = H.lastFile = path
127 return !!path
128 }
129 if (e.tool !== 'Write' && e.tool !== 'Edit') return false
130 let raw = ''
131 try {
132 raw = JSON.stringify(e.input || e.args || e.params || e)
133 } catch {
134 return false
135 }
136 const m = HANDOFF_FILE.exec(raw.replace(/\\\\/g, '/'))
137 if (m) H.file = H.lastFile = m[0]
138 return !!m
139}
140
141// A header status other than active (consumed) retires a handoff file.
142// Free-hand handoffs often lack the skill's <!-- handoff: --> header.
143function retired(text) {
144 const status = /status:\s*(\w+)/i.exec(text.split('\n', 3).join('\n'))
145 return !!status && status[1].toLowerCase() !== 'active'
146}
147
148async function fileRetired($, path) {
149 try {
150 return !(await $.fs.exists(path)) || retired(await $.fs.read(path))
151 } catch {
152 return false
153 }
154}
155
156// A handoff file becomes the ready handoff unless its header retires it.
157// early: right after the write, mid-turn; the turn end checks the file again.
158async function captureHandoffFile($, path = H.file, early = false) {
159 if (!early) H.file = ''
160 try {
161 if (!path || !(await $.fs.exists(path))) return false
162 const text = (await $.fs.read(path)).replace(/^/, '').trim()
163 if (text.length < 200 || retired(text)) return false
164 const known = H.fromFile && H.path === path && H.text === text
165 H.text = text
166 H.path = path
167 H.fromFile = true
168 if (!known) H.announce = true
169 if (early) {
170 $.ui.invalidate('ui.render')
171 return true
172 }
173 if (H.announce) $.ui.toast(`Handoff file written: ${path}. Press c on the band or type /${names.handoff} continue to clear and continue.`, { timeoutMs: 15000 })
174 H.announce = false
175 const askAfter = H.askAfter
176 H.armed = false
177 H.notTurn = ''
178 H.askAfter = false
179 // Off the hook: the turn is ending, and a dialog would hold it open
180 if (askAfter) $.clock.after(300, () => offerContinue($).catch(() => {}))
181 return true
182 } catch {
183 return false
184 }
185}
186
187function armHandoff(insideTurn) {
188 H.armed = true
189 H.notTurn = insideTurn ? '' : S.working ? S.turnId : ''
190}
191
192// The handoff without any preamble Claude put before its heading
193function handoffBody(answer) {
194 const text = String(answer || '').trim()
195 const i = text.indexOf('# Session Handoff')
196 return i > 0 ? text.slice(i) : text
197}
198
199function fileStamp(ms) {
200 const d = new Date(ms)
201 const two = (n) => String(n).padStart(2, '0')
202 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}`
203}
204
205async function saveHandoff($, text) {
206 const project = basename(S.cwd).replace(/[^A-Za-z0-9-]+/g, '-') || 'session'
207 const path = `${home || '.'}/.claude/mods-data/session-band/handoffs/${fileStamp(now)}-${project}-${S.id.slice(0, 8)}.md`.replace(/\\/g, '/')
208 await $.fs.write(path, text + '\n')
209 return path
210}
211
212async function captureHandoff($, e) {
213 H.armed = false
214 H.notTurn = ''
215 const askAfter = H.askAfter
216 H.askAfter = false
217 const body = e.reason === 'answer' ? handoffBody(e.answer) : ''
218 if (body.length < 200) {
219 // A Stop hook can end the turn on a short tail; the skill's file still holds the handoff
220 H.askAfter = askAfter
221 if (await captureHandoffFile($, H.lastFile)) return
222 H.askAfter = false
223 $.ui.toast('The handoff turn ended without a handoff, so nothing was saved.', { timeoutMs: 8000 })
224 return
225 }
226 H.text = body
227 try {
228 H.path = await saveHandoff($, body)
229 } catch {
230 H.path = ''
231 }
232 const saved = H.path ? `Handoff saved to ${H.path}.` : 'Handoff captured (the backup file could not be written).'
233 $.ui.toast(`${saved} Press c on the band or type /${names.handoff} continue to clear and continue.`, { timeoutMs: 15000 })
234 // Off the hook: the turn is ending, and a dialog would hold it open
235 if (askAfter) $.clock.after(300, () => offerContinue($).catch(() => {}))
236}
237
238async function offerContinue($) {
239 if (!H.text) return
240 let answer = ''
241 try {
242 answer = await $.ui.ask('Handoff saved. Clear this chat and continue with it in a fresh context?', ['Clear and continue', 'Keep this chat'])
243 } catch {
244 return // dismissed: the band button and /handoff continue still work
245 }
246 if (answer === 'Clear and continue') await clearAndContinue($)
247}
248
249function continuationPrompt() {
250 if (H.fromFile) return `Continue from session handoff: read ${H.path}, consume it with session-handoff, do not re-explore.`
251 const where = H.path ? ` (saved at ${H.path})` : ''
252 return `Handoff from my previous session${where}:\n\n${H.text}`
253}
254
255async function clearAndContinue($) {
256 if (H.continuing) return
257 if (!H.text) {
258 $.ui.toast(`No handoff ready. Press h on the band or type /${names.handoff} first.`, { timeoutMs: 8000 })
259 return
260 }
261 if (S.working) {
262 $.ui.toast('Claude is still working. Clear and continue once this turn ends.', { timeoutMs: 8000 })
263 return
264 }
265 const text = continuationPrompt()
266 const path = H.path
267 H.continuing = true
268 $.ui.invalidate('ui.render')
269 try {
270 await $.command.run({ command: 'clear', args: '' })
271 } catch (err) {
272 H.continuing = false
273 $.ui.invalidate('ui.render')
274 $.ui.toast('Could not run /clear: ' + clip(String((err && err.message) || err), 100) + (path ? ` The handoff is saved at ${path}.` : ''), { timeoutMs: 10000 })
275 return
276 }
277 H.text = ''
278 H.path = ''
279 H.fromFile = false
280 // our own prompt.submit hook never sees this submission
281 H.resuming = true
282 try {
283 await $.prompt.submit({ text, asUser: true })
284 } catch {
285 // Not sent: leave it in the prompt box for one Enter
286 let filled = false
287 try {
288 filled = !!(await $.prompt.fill({ text })).isFilled
289 } catch {
290 filled = false
291 }
292 $.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 })
293 } finally {
294 H.continuing = false
295 $.ui.invalidate('ui.render')
296 }
297}
298
299// Runs the /session-handoff skill as if you typed it. The engine queues it
300// until Claude finishes the current turn.
301async function runHandoff($) {
302 if (handoffPending) {
303 $.ui.toast('Session handoff is already queued.')
304 return
305 }
306 try {
307 const commands = await $.command.list()
308 const cmd = commands.find((c) => c.name === 'session-handoff') || commands.find((c) => HANDOFF_SKILL.test(c.name))
309 if (!cmd) {
310 H.askAfter = false
311 $.ui.toast('No /session-handoff skill in this session.', { timeoutMs: 8000 })
312 return
313 }
314 handoffPending = true
315 armHandoff(false)
316 $.ui.invalidate('ui.render')
317 $.ui.toast(S.working ? 'Session handoff queued: it runs when Claude finishes this turn.' : 'Running /session-handoff.')
318 await $.command.run({ command: cmd.name, args: '' })
319 } catch (err) {
320 H.armed = false
321 H.askAfter = false
322 $.ui.toast('Could not start /session-handoff: ' + clip(String((err && err.message) || err), 100), { timeoutMs: 8000 })
323 } finally {
324 handoffPending = false
325 $.ui.invalidate('ui.render')
326 }
327}
328
329function ttlMin() {
330 return settings.ttlMin || S.ttlMin
331}
332
333// Plan limit windows as the API reports them: five_hour, seven_day, spend_limit;
334// a per-model weekly window takes its model's initial ("fw" for Fable)
335const LIMIT_LABEL = { five_hour: '5h', seven_day: 'w', spend_limit: 'spend' }
336const limitLabel = (l) => LIMIT_LABEL[l.kind] || (l.model ? `${l.model[0].toLowerCase()}w` : l.kind)
337const LIMIT_NAME = { five_hour: '5-hour window', seven_day: 'weekly, all models', spend_limit: 'spend limit' }
338const limitName = (l) => LIMIT_NAME[l.kind] || (l.model ? `weekly, ${l.model} only` : '')
339
340function limitsText(limits, sep = ' · ') {
341 return limits.map((l) => `${limitLabel(l)} ${Math.round(l.percentUsed)}%`).join(sep)
342}
343
344// Each window's name and reset: "5h (5-hour window) resets 1:50pm (in 2h15m) · w (weekly, all models) resets Fri 9:00am (in 3d21h)"
345const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
346function resetsText(limits, at) {
347 return limits.map((l) => {
348 const name = limitName(l) ? `${limitLabel(l)} (${limitName(l)})` : limitLabel(l)
349 if (!l.resetsAt) return name
350 const t = Date.parse(l.resetsAt)
351 const left = t - at
352 const day = left >= 24 * 60 * MIN ? DAYS[new Date(t).getDay()] + ' ' : ''
353 const span = left <= 0 ? 'due' : left >= 24 * 60 * MIN ? `in ${Math.floor(left / (24 * 60 * MIN))}d${Math.floor((left % (24 * 60 * MIN)) / (60 * MIN))}h` : `in ${minutes(left)}`
354 return `${name} resets ${day}${clock(t)} (${span})`
355 }).join(' · ')
356}
357
358function allLimits() {
359 return [...S.rateLimits, ...modelLimits]
360}
361
362// The engine hands mods only five_hour, seven_day and spend_limit; per-model
363// weekly windows are rows of kind weekly_scoped in the usage endpoint's limits[].
364// Undocumented: a changed shape or failed request leaves the last rows.
365const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
366const USAGE_EVERY = 5 * MIN
367
368async function refreshModelLimits($) {
369 if (now - usageAskedAt < USAGE_EVERY) return
370 usageAskedAt = now
371 try {
372 const auth = await $.session.authorize()
373 if (!auth || auth.kind !== 'bearer') return
374 const r = await $.http.fetch(USAGE_URL, { auth: auth.handle, headers: { 'anthropic-beta': 'oauth-2025-04-20' } })
375 if (!r.ok) return
376 const rows = JSON.parse(r.text).limits
377 if (!Array.isArray(rows)) return
378 modelLimits = rows
379 .filter((l) => l && l.kind === 'weekly_scoped' && typeof l.percent === 'number' && typeof l.scope?.model?.display_name === 'string')
380 .map((l) => {
381 const model = l.scope.model.display_name
382 const t = Date.parse(l.resets_at)
383 return { kind: 'weekly_' + model.toLowerCase(), model, percentUsed: l.percent, ...(t ? { resetsAt: new Date(Math.round(t / MIN) * MIN).toISOString() } : {}) }
384 })
385 $.ui.invalidate('ui.render')
386 await heartbeat($, true)
387 } catch {
388 // endpoint unavailable: keep the last rows
389 }
390}
391
392function limitTone(limits, extra) {
393 const top = Math.max(...limits.map((l) => l.percentUsed || 0))
394 if (top >= 95) return { ...extra, color: 'red', bold: true }
395 if (top >= 80) return { ...extra, color: 'yellow' }
396 return { ...extra, dimColor: true }
397}
398
399function money(v) {
400 return rec.strict ? '$•••' : usd(v)
401}
402
403function msLeft(lastActivity, ttl) {
404 if (!lastActivity) return null
405 return ttl * MIN - (now - lastActivity)
406}
407
408function cacheState(hb) {
409 const left = msLeft(hb.lastActivity, hb.ttlMin)
410 if (left === null) return { kind: 'unknown', left: 0 }
411 if (hb.keepWarm) return { kind: 'kept', left }
412 if (left <= 0) return { kind: 'cold', left }
413 if (left <= 5 * MIN) return { kind: 'cooling', left }
414 return { kind: 'warm', left }
415}
416
417function myHeartbeat() {
418 const coldUsd = S.coldRestarts.reduce((a, c) => a + c.usd, 0)
419 let state = 'idle'
420 if (S.waitingSince && now - S.waitingSince > 5000) state = 'waiting'
421 else if (S.working) state = 'working'
422 return {
423 id: S.id,
424 label: S.label,
425 cwd: S.cwd,
426 model: S.model,
427 state,
428 waitingFor: S.waitingFor,
429 tool: S.tool,
430 ctx: S.ctx,
431 window: S.window,
432 ttlMin: ttlMin(),
433 lastActivity: S.lastActivity,
434 keepWarm: S.keepWarm,
435 keepWarmUntil: S.keepWarmUntil,
436 pings: S.pings,
437 pingUsd: S.pingUsd,
438 costUsd: S.costUsd,
439 coldCount: S.coldRestarts.length,
440 coldUsd,
441 agentsRunning: S.agentsRunning,
442 lastTurnEnd: S.lastTurnEnd,
443 updatedAt: now,
444 }
445}
446
447async function registerCommand($, name, description, argumentHint, immediate) {
448 const spec = immediate ? { name, description, argumentHint, immediate: true } : { name, description, argumentHint }
449 try {
450 await $.command.register(spec)
451 return name
452 } catch {
453 try {
454 await $.command.register({ ...spec, name: 'ck-' + name })
455 return 'ck-' + name
456 } catch {
457 return null
458 }
459 }
460}
461
462async function readRecording($) {
463 try {
464 const path = (home || '.') + '/.claude/mods-data/recording.json'
465 if (!(await $.fs.exists(path))) {
466 rec = { on: false, strict: false }
467 } else {
468 const flag = JSON.parse(await $.fs.read(path))
469 rec = { on: !!flag.on, strict: !!flag.on && !!flag.strict }
470 }
471 } catch {
472 rec = { on: false, strict: false }
473 }
474 mask = rec.on ? makeMasker({ strict: rec.strict }) : (s) => s
475}
476
477async function heartbeat($, force) {
478 if (!S.id) return
479 if (!force && now - lastHeartbeat < 5000) return
480 lastHeartbeat = now
481 try {
482 const agents = await $.agent.list()
483 S.agentsRunning = agents.filter((a) => a.status === 'running').length
484 } catch {
485 // agent list unavailable: keep the last count
486 }
487 const hb = myHeartbeat()
488 await $.store.set('hb:' + S.id, hb)
489 await writeLive($, hb, false)
490}
491
492// One file per session for tools outside Claude Code (the VS Code status bar
493// extension): the heartbeat plus plan limits, rewrite price and surfaces.
494async function writeLive($, hb, ended) {
495 if (!home || !hb.id) return
496 try {
497 let surfaces = []
498 try {
499 surfaces = await $.session.surfaces()
500 } catch {
501 // surfaces unavailable: the reader falls back to matching the cwd
502 }
503 lastLiveHandoff = handoffState()
504 const live = { ...hb, surfaces, rateLimits: allLimits(), rewriteUsd: rewriteCost(S.ctx, S.model, ttlMin()), bigTokens: settings.bigTokens, masked: rec.strict, handoff: lastLiveHandoff, ended }
505 await $.fs.write(outsidePath('live', hb.id), JSON.stringify(live))
506 } catch {
507 // a failed write only leaves the outside readers stale
508 }
509}
510
511// A chat this process enters (launch, --resume, /clear, /resume) starts with
512// nothing in memory: context, cost and limits come from the engine (local
513// estimate, no request); a reopened chat also brings its cache time and an
514// active handoff file it wrote earlier.
515async function restoreSession($) {
516 try {
517 const u = await $.session.usage({ breakdown: 'summary' })
518 const c = u.context || {}
519 S.ctx = c.tokens || (c.breakdown && c.breakdown.totalTokens) || S.ctx
520 if (c.window) S.window = c.window
521 if (u.cost) S.costUsd = u.cost.usd
522 if (Array.isArray(u.rateLimits) && u.rateLimits.length) S.rateLimits = u.rateLimits
523 } catch {
524 // usage unavailable: the first request fills it in
525 }
526 let messages = []
527 try {
528 messages = await $.session.messages()
529 } catch {
530 // no messages: nothing more to restore
531 }
532 if (messages.length) {
533 S.lastActivity = await lastResponseAt($)
534 for (const m of messages) for (const t of m.toolUses || []) noteHandoffFile(t)
535 H.file = ''
536 if (H.lastFile) await captureHandoffFile($, H.lastFile)
537 }
538 await heartbeat($, true)
539}
540
541// When the transcript's last model response was written. Not the file's mtime:
542// a resume appends rows. Claude Code names the folder after the launch cwd.
543async function lastResponseAt($) {
544 const path = `${home}/.claude/projects/${S.cwd.replace(/[^A-Za-z0-9]/g, '-')}/${S.id}.jsonl`.replace(/\\/g, '/')
545 let lines = []
546 try {
547 lines = (await $.fs.read(path)).split('\n')
548 } catch {
549 return 0 // no transcript there: the cache state stays unknown
550 }
551 for (let i = lines.length - 1; i >= 0; i--) {
552 if (!lines[i].includes('"assistant"')) continue
553 try {
554 const row = JSON.parse(lines[i])
555 if (row.type === 'assistant' && row.timestamp) return Date.parse(row.timestamp) || 0
556 } catch {
557 // a half-written last line
558 }
559 }
560 return 0
561}
562
563// /clear, /resume and /branch go on under a new id without a session.start, and
564// classic.SessionStart never reaches a mod: follow $.session.id() instead.
565async function followSessionId($) {
566 let id = ''
567 try {
568 id = await $.session.id()
569 } catch {
570 return
571 }
572 if (!id || id === S.id || id === endedId) return
573 S.id = id
574 S.label = basename(S.cwd)
575 S.lastActivity = 0
576 S.keepWarm = false
577 H.armed = false
578 H.file = H.lastFile = '' // the previous chat's handoff file is not this chat's
579 H.announce = false
580 await restoreSession($)
581}
582
583function outsidePath(dir, id) {
584 return `${home}/.claude/mods-data/session-band/${dir}/${id}.json`.replace(/\\/g, '/')
585}
586
587// What the band's handoff button shows, in the order the band picks it
588function handoffState() {
589 if (H.continuing) return 'clearing'
590 if (ready()) return 'ready'
591 if (handoffPending) return 'queued'
592 if (H.armed) return 'running'
593 return 'idle'
594}
595
596// The VS Code extension's handoff buttons: one command file per session, marked
597// done instead of deleted. Old commands are dropped so a reload never replays one.
598async function applyFileCommand($) {
599 const path = outsidePath('commands', S.id)
600 let c
601 try {
602 if (!(await $.fs.exists(path))) return
603 c = JSON.parse(await $.fs.read(path))
604 } catch {
605 return
606 }
607 if (!c || c.doneAt || !c.at || c.at <= lastCommandAt) return
608 lastCommandAt = c.at
609 await $.fs.write(path, JSON.stringify({ ...c, doneAt: now }))
610 if (now - c.at > 30000) return
611 // not awaited: a queued handoff waits for the turn to end
612 if (c.cmd === 'handoff') runHandoff($).catch(() => {})
613 else if (c.cmd === 'continue') clearAndContinue($).catch(() => {})
614}
615
616async function outsideStep($) {
617 if (!home || outsideBusy) return
618 outsideBusy = true
619 try {
620 now = await $.clock.now()
621 await followSessionId($)
622 if (!S.id) return
623 await applyFileCommand($)
624 // another chat consumed (or removed) the ready handoff's file
625 if (H.text && H.fromFile && !H.continuing && (await fileRetired($, H.path))) {
626 H.text = ''
627 H.path = ''
628 H.fromFile = false
629 $.ui.invalidate('ui.render')
630 }
631 if (handoffState() !== lastLiveHandoff) await heartbeat($, true)
632 } finally {
633 outsideBusy = false
634 }
635}
636
637async function readFleet($) {
638 const keys = (await $.store.keys()).filter((k) => k.startsWith('hb:'))
639 const list = []
640 for (const k of keys) {
641 const hb = await $.store.get(k)
642 if (!hb || typeof hb !== 'object') continue
643 if (hb.id === S.id) continue
644 // a session that stopped heartbeating an hour ago has ended or crashed
645 if (now - (hb.updatedAt || 0) > 60 * MIN) {
646 await $.store.delete(k)
647 continue
648 }
649 list.push({ ...hb, stale: now - (hb.updatedAt || 0) > 3 * MIN })
650 }
651 list.push({ ...myHeartbeat(), self: true })
652 fleet = list
653}
654
655// Commands another session's board sent this one, through the shared store
656async function applyRemoteCommands($) {
657 const c = await $.store.get('cmd:' + S.id)
658 if (!c) return
659 await $.store.delete('cmd:' + S.id)
660 if (c.keepWarm === true) startKeepWarm($, settings.keepWarmHours)
661 if (c.keepWarm === false) stopKeepWarm($, 'stopped from the board')
662}
663
664function startKeepWarm($, hours) {
665 S.keepWarm = true
666 S.keepWarmUntil = now + hours * 60 * MIN
667 $.ui.toast(`Keeping this cache warm until ${clock(S.keepWarmUntil)}. A ping reads the cache before it expires.`)
668}
669
670function stopKeepWarm($, why) {
671 if (!S.keepWarm) return
672 S.keepWarm = false
673 $.ui.toast(`Keep warm off (${why}). ${S.pings} ping(s), ${money(S.pingUsd)}.`, { timeoutMs: 8000 })
674}
675
676async function keepWarmStep($) {
677 if (!S.keepWarm) return
678 if (now >= S.keepWarmUntil) return stopKeepWarm($, 'time limit reached')
679 if (S.working || !S.lastActivity) return
680 const ttl = ttlMin()
681 const margin = ttl >= 60 ? 8 * MIN : 90000
682 if (now - S.lastActivity < ttl * MIN - margin) return
683 let reply = null
684 try {
685 reply = await $.model.fork({ prompt: 'session-band keep-alive ping. Reply with only: ok' })
686 } catch {
687 reply = null
688 }
689 if (!reply || !reply.usage) {
690 return stopKeepWarm($, 'the ping got no answer, so the cache may already be cold')
691 }
692 const u = reply.usage
693 S.pings += 1
694 S.lastPingAt = now
695 S.pingUsd += requestCost(u, S.model, ttl)
696 // A ping that writes instead of reading did not hit the cache: stop paying for it
697 if ((u.cache_creation_input_tokens || 0) > 0.1 * Math.max(1, u.cache_read_input_tokens || 0)) {
698 return stopKeepWarm($, `the ping wrote ${tokens(u.cache_creation_input_tokens)} tokens instead of reading the cache`)
699 }
700 S.lastActivity = now
701}
702
703function warnStep($) {
704 if (!settings.alerts) return
705 for (const hb of fleet) {
706 if (hb.stale || hb.keepWarm || (hb.ctx || 0) < settings.bigTokens) continue
707 const st = cacheState(hb)
708 if (st.kind !== 'cooling') continue
709 const key = 'cool:' + hb.lastActivity
710 if (alerted.get(hb.id) === key) continue
711 alerted.set(hb.id, key)
712 const cost = money(rewriteCost(hb.ctx, hb.model, hb.ttlMin))
713 const who = hb.self ? 'This session' : `"${mask(hb.label)}"`
714 const how = hb.self ? `type /${names.keepwarm} or press 1` : `open /${names.board} to keep it warm`
715 $.ui.toast(`${who}: ${tokens(hb.ctx)}-token cache goes cold in ${minutes(st.left)}. Rewriting it costs about ${cost}. ${how}.`, { timeoutMs: 15000 })
716 }
717 // Another session started waiting on a permission or a question
718 for (const hb of fleet) {
719 if (hb.self || hb.stale) continue
720 const prev = alerted.get('state:' + hb.id)
721 alerted.set('state:' + hb.id, hb.state)
722 if (hb.state === 'waiting' && prev && prev !== 'waiting') {
723 $.ui.toast(`"${mask(hb.label)}" is waiting on you${hb.waitingFor ? ': ' + hb.waitingFor : ''}`, { timeoutMs: 10000 })
724 }
725 }
726}
727
728async function tick($) {
729 now = await $.clock.now()
730 await readRecording($)
731 await applyRemoteCommands($)
732 await keepWarmStep($)
733 await heartbeat($, true)
734 await readFleet($)
735 warnStep($)
736 $.ui.invalidate('ui.render')
737}
738
739async function loadSettings($) {
740 const saved = await $.store.get('settings')
741 if (saved && typeof saved === 'object') Object.assign(settings, saved)
742}
743
744function statusText() {
745 const st = cacheState(myHeartbeat())
746 const ttl = ttlMin()
747 const lines = []
748 lines.push(`Session Band: ${tokens(S.ctx)} tokens in context on ${priceFor(S.model).id}, cache window ${ttl} min (${settings.ttlMin ? 'set by you' : S.ttlSource}).`)
749 if (st.kind === 'unknown') lines.push('No request yet this session, so the cache state is unknown.')
750 if (st.kind === 'warm' || st.kind === 'cooling') lines.push(`Cache is warm for about ${minutes(st.left)} more.`)
751 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))}.`)
752 if (S.keepWarm) lines.push(`Keep warm is on until ${clock(S.keepWarmUntil)}: ${S.pings} ping(s), ${money(S.pingUsd)} so far.`)
753 if (allLimits().length) lines.push(`Plan limits used: ${limitsText(allLimits())}.`)
754 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))}).`)
755 lines.push(`Cold-send guard: ${settings.guard ? 'on' : 'off'} for contexts over ${tokens(settings.bigTokens)} tokens. Alerts: ${settings.alerts ? 'on' : 'off'}.`)
756 lines.push(`Settings: /${names.cache} ttl 5|60|auto, /${names.cache} guard on|off, /${names.cache} big 150k, /${names.cache} alerts [on|off] (no value toggles). Board: /${names.board}.`)
757 return lines.join('\n')
758}
759
760function parseTokens(text) {
761 const m = String(text || '').trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([km]?)$/)
762 if (!m) return null
763 return Math.round(Number(m[1]) * (m[2] === 'm' ? 1e6 : m[2] === 'k' ? 1e3 : 1))
764}
765
766export function register(on) {
767 on('session.start', async ($, e, next) => {
768 now = await $.clock.now()
769 home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
770 S.id = await $.session.id()
771 S.cwd = await $.session.cwd()
772 S.label = basename(S.cwd)
773 S.model = await $.session.model()
774 await loadSettings($)
775 await readRecording($)
776 names.cache = (await registerCommand($, 'cache', 'Session Band status and settings', '[ttl 5|60|auto] [guard on|off] [big 150k] [alerts [on|off]]')) || names.cache
777 names.keepwarm = (await registerCommand($, 'keepwarm', 'Keep this session\'s prompt cache warm (default 4 hours), or /keepwarm off', '[hours|off]', true)) || names.keepwarm
778 names.board = (await registerCommand($, 'board', 'Every local Claude Code session: state, context, cache, cost', '', true)) || names.board
779 names.handoff = (await registerCommand($, 'handoff', 'Session handoff, then clear this chat and continue with it (/handoff continue)', '[continue]')) || names.handoff
780 $.clock.every(HEARTBEAT_EVERY, () => tick($).catch(() => {}))
781 $.clock.every(OUTSIDE_EVERY, () => outsideStep($).catch(() => {}))
782 await restoreSession($)
783 refreshModelLimits($).catch(() => {})
784 return next(e)
785 })
786
787 // A /clear or /resume ends the chat but not the process: no heartbeat may
788 // write this id again, and followSessionId picks up the next one.
789 on('session.end', async ($, e, next) => {
790 if (S.id) {
791 await $.store.delete('hb:' + S.id)
792 await writeLive($, myHeartbeat(), true)
793 endedId = S.id
794 S.id = ''
795 }
796 return next(e)
797 })
798
799 // The first prompt names the session on the board
800 on('prompt.submit', async ($, e, next) => {
801 now = await $.clock.now()
802 await followSessionId($) // a prompt right after /clear: the new chat before its first request
803 H.resuming = RESUME_LINE.test(String(e.text || ''))
804 if (S.label === basename(S.cwd) && e.origin && e.origin.kind === 'composer' && e.text) {
805 S.label = basename(S.cwd) + ' · ' + clip(e.text, 40)
806 }
807 // A new message in this chat makes a waiting handoff out of date (its file stays)
808 if (H.text && !H.continuing && e.origin && e.origin.kind === 'composer' && !String(e.text || '').trim().startsWith('/')) {
809 H.text = ''
810 H.path = ''
811 H.fromFile = false
812 $.ui.invalidate('ui.render')
813 }
814 const ttl = ttlMin()
815 const left = msLeft(S.lastActivity, ttl)
816 const isCold = left !== null && left <= 0
817 const fromUser = e.origin && (e.origin.kind === 'composer' || e.origin.kind === 'bridge')
818 if (!settings.guard || !fromUser || !isCold || S.ctx < settings.bigTokens || e.turnId) return next(e)
819 const cost = money(rewriteCost(S.ctx, S.model, ttl))
820 let answer = 'Send anyway'
821 try {
822 answer = await $.ui.ask(
823 `Cache went cold ${minutes(-left)} ago. Sending now rewrites ${tokens(S.ctx)} tokens of context (about ${cost}). What should happen?`,
824 ['Send anyway', 'Compact first, then send', 'Cancel'],
825 )
826 } catch {
827 // nobody to ask (a -p run, or the dialog was dismissed): send as typed
828 return next(e)
829 }
830 if (answer === 'Compact first, then send') {
831 try {
832 await $.session.compact()
833 } catch {
834 // compaction refused or failed: send anyway
835 }
836 return next(e)
837 }
838 if (answer === 'Send anyway') return next(e)
839 $.ui.toast('Not sent. A fresh session with a short handoff avoids the rewrite entirely.', { timeoutMs: 8000 })
840 return { drop: 'Cancelled by session-band before a cold cache rewrite' }
841 })
842
843 on('turn.start', async ($, e, next) => {
844 now = await $.clock.now()
845 S.working = true
846 if (e.turnId) S.turnId = e.turnId
847 await heartbeat($, true)
848 return next(e)
849 })
850
851 // Each main-loop request: did it read the cache, or rewrite it?
852 on('turn.step', async function* ($, e, next) {
853 const startedAt = await $.clock.now()
854 const result = yield* next(e)
855 if (e.agentId || !result || !result.usage) return result
856 now = await $.clock.now()
857 const u = result.usage
858 const total = totalInput(u)
859 const written = u.cache_creation_input_tokens || 0
860 const gap = S.lastActivity ? startedAt - S.lastActivity : 0
861 S.model = u.model || S.model
862 const afterCompact = justCompacted
863 justCompacted = false
864 if (afterCompact) {
865 // the first request after a compaction writes the new, shorter context: expected
866 } else if (S.lastActivity && total > 30000 && written / total > 0.5) {
867 // A rewrite after 5-60 idle minutes means this session runs on the 5-minute TTL
868 if (!settings.ttlMin && gap > 5.5 * MIN && gap < S.ttlMin * MIN) {
869 S.ttlMin = 5
870 S.ttlSource = 'measured'
871 }
872 S.coldRestarts.push({ at: now, tokens: written, usd: rewriteCost(written, S.model, ttlMin()), gapMin: Math.round(gap / MIN) })
873 } else if (S.lastActivity && total > 30000 && gap > 5.5 * MIN && cachedShare(u) > 0.8) {
874 // A hit after more than 5 idle minutes proves the 1-hour TTL
875 if (!settings.ttlMin) {
876 S.ttlMin = 60
877 S.ttlSource = 'measured'
878 }
879 }
880 S.ctx = total
881 S.lastActivity = now
882 return result
883 })
884
885 on('turn.complete', async ($, e, next) => {
886 const r = await next(e)
887 if (e.agentId) return r
888 now = await $.clock.now()
889 S.working = false
890 S.waitingSince = 0
891 S.tool = ''
892 S.lastTurnEnd = now
893 try {
894 const usage = await $.session.usage()
895 if (usage.context && usage.context.tokens) S.ctx = usage.context.tokens
896 if (usage.context && usage.context.window) S.window = usage.context.window
897 if (usage.cost) S.costUsd = usage.cost.usd
898 if (Array.isArray(usage.rateLimits) && usage.rateLimits.length) S.rateLimits = usage.rateLimits
899 } catch {
900 // usage unavailable: keep the per-request figures
901 }
902 const wrote = H.file
903 if (wrote && (await captureHandoffFile($))) {
904 // the file is the handoff; the turn's answer is only its summary
905 } else if (wrote && (await fileRetired($, wrote))) {
906 // the turn consumed a handoff: its answer is not a new one
907 H.armed = false
908 H.notTurn = ''
909 H.askAfter = false
910 } else if (H.armed && e.turnId !== H.notTurn) await captureHandoff($, e)
911 refreshModelLimits($).catch(() => {})
912 await heartbeat($, true)
913 $.ui.invalidate('ui.render')
914 return r
915 })
916
917 on('session.compact', async ($, e, next) => {
918 const r = await next(e)
919 justCompacted = true
920 return r
921 })
922
923 on('session.measure', async ($, e, next) => {
924 if (e.context && e.context.tokens) S.ctx = e.context.tokens
925 if (e.context && e.context.window) S.window = e.context.window
926 if (e.cost) S.costUsd = e.cost.usd
927 if (Array.isArray(e.rateLimits) && e.rateLimits.length) S.rateLimits = e.rateLimits
928 return next(e)
929 })
930
931 // Waiting on the person: a permission prompt or a question
932 on('tool.check', async ($, e, next) => {
933 const d = await next(e)
934 if (d && d.decision === 'ask' && !S.waitingSince) {
935 now = await $.clock.now()
936 S.waitingSince = now
937 S.waitingFor = 'permission for ' + e.tool
938 }
939 return d
940 })
941
942 on('tool.call', async ($, e, next) => {
943 if (!e.agentId) S.tool = e.tool
944 // Claude calling the handoff skill itself: this turn's answer is the handoff
945 if (!e.agentId && e.tool === 'Skill' && HANDOFF_SKILL.test(String(e.skill || '')) && !H.resuming) armHandoff(true)
946 if (e.tool === 'AskUserQuestion') {
947 now = await $.clock.now()
948 S.waitingSince = now
949 S.waitingFor = 'a question'
950 }
951 try {
952 const r = await next(e)
953 // a denied or failed write left the file as it was
954 if (!e.agentId && r && !r.deny && !r.isError && noteHandoffFile(e)) await captureHandoffFile($, H.file, true)
955 return r
956 } finally {
957 if (S.waitingSince) {
958 S.waitingSince = 0
959 S.waitingFor = ''
960 }
961 }
962 })
963
964 // /session-handoff from anywhere (typed, the band, /handoff): watch for its answer
965 on('command.run', async ($, e, next) => {
966 if (HANDOFF_SKILL.test(String(e.command || ''))) armHandoff(false)
967 return next(e)
968 })
969
970 // /handoff runs the handoff and then asks to clear and continue (the band's
971 // buttons draw only in the terminal); /handoff continue does the second half.
972 // Both run off the hook: a command started inside a hook the session is
973 // waiting on is refused.
974 on('command.run', { command: ['handoff', 'ck-handoff'] }, async ($, e) => {
975 now = await $.clock.now()
976 const arg = String(e.args || '').trim().toLowerCase()
977 if (arg === 'continue' || arg === 'go') {
978 if (!H.text) return { text: `No handoff ready yet. Type /${names.handoff} to make one.` }
979 $.clock.after(50, () => clearAndContinue($).catch(() => {}))
980 return { text: 'Clearing this chat and continuing with the handoff.' }
981 }
982 H.askAfter = true
983 $.clock.after(50, () => runHandoff($).catch(() => {}))
984 return { text: 'Running the session handoff. When it finishes, you can clear this chat and continue with it.' }
985 })
986
987 on('command.run', { command: ['cache', 'ck-cache'] }, async ($, e) => {
988 now = await $.clock.now()
989 const [key, value] = String(e.args || '').trim().toLowerCase().split(/\s+/)
990 if (key === 'ttl') {
991 settings.ttlMin = value === '5' ? 5 : value === '60' ? 60 : 0
992 } else if (key === 'guard' || key === 'alerts') {
993 // no value toggles
994 settings[key] = value === undefined ? !settings[key] : value !== 'off'
995 } else if (key === 'big') {
996 const n = parseTokens(value)
997 if (n) settings.bigTokens = n
998 }
999 if (key) {
1000 await $.store.set('settings', settings)
1001 $.ui.invalidate('ui.render')
1002 }
1003 return { text: statusText() }
1004 })
1005
1006 on('command.run', { command: ['keepwarm', 'ck-keepwarm'] }, async ($, e) => {
1007 now = await $.clock.now()
1008 const arg = String(e.args || '').trim().toLowerCase()
1009 if (arg === 'off' || (arg === '' && S.keepWarm)) {
1010 stopKeepWarm($, 'turned off')
1011 } else {
1012 const hours = Number(arg) > 0 ? Math.min(24, Number(arg)) : settings.keepWarmHours
1013 startKeepWarm($, hours)
1014 }
1015 await heartbeat($, true)
1016 $.ui.invalidate('ui.render')
1017 return {}
1018 })
1019
1020 on('command.run', { command: ['board', 'ck-board'] }, async ($, e) => {
1021 now = await $.clock.now()
1022 await heartbeat($, true)
1023 await readFleet($)
1024 const surface = await $.session.surface()
1025 if (!surface) return { text: boardText() }
1026 paneOpen = true
1027 await $.ui.open({ id: PANE, title: 'Sessions', focus: true, closeOnEscape: true })
1028 return {}
1029 })
1030
1031 on('ui.close', async ($, e, next) => {
1032 if (e.id === PANE) paneOpen = false
1033 return next(e)
1034 })
1035
1036 // The band above the prompt: this session's cache at a glance
1037 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1038 const below = await next(e)
1039 if (e.props && e.props.hasSurvey) return below
1040 if (!S.lastActivity && !S.keepWarm) return below
1041 const { Box, Text, Button } = $.ui.resolve(e)
1042 const hb = myHeartbeat()
1043 const st = cacheState(hb)
1044 const ttl = ttlMin()
1045 const big = S.ctx >= settings.bigTokens
1046 const parts = []
1047 const tip = (scope) => ({ scope, underline: true })
1048 if (st.kind === 'kept') parts.push(Text({ color: 'cyan', hover: tip('ck-c'), children: [`◆ c kept ${clock(S.keepWarmUntil)}`] }))
1049 else if (st.kind === 'warm') parts.push(Text({ color: 'green', hover: tip('ck-c'), children: [`● c ${minutes(st.left)}`] }))
1050 else if (st.kind === 'cooling') parts.push(Text({ color: 'yellow', bold: true, hover: tip('ck-c'), children: [`◐ c ${minutes(st.left)}`] }))
1051 else if (st.kind === 'cold') parts.push(Text(big ? { color: 'red', bold: true, hover: tip('ck-c'), children: [`○ c cold ${minutes(-st.left)}`] } : { dimColor: true, hover: tip('ck-c'), children: [`○ c cold ${minutes(-st.left)}`] }))
1052 parts.push(Text({ dimColor: true, hover: tip('ck-ctx'), children: [` │ ctx ${tokens(S.ctx)}`] }))
1053 if (st.kind === 'cold' && big) parts.push(Text({ color: 'red', hover: tip('ck-rewrite'), children: [` │ next send rewrites it ≈ ${money(rewriteCost(S.ctx, S.model, ttl))}`] }))
1054 else parts.push(Text({ dimColor: true, hover: tip('ck-rewrite'), children: [` │ rwc ${money(rewriteCost(S.ctx, S.model, ttl))}`] }))
1055 parts.push(Text({ dimColor: true, hover: tip('ck-sc'), children: [` │ sc ${money(S.costUsd)}`] }))
1056 const cold = S.coldRestarts.length
1057 const coldUsd = money(S.coldRestarts.reduce((a, c) => a + c.usd, 0))
1058 if (cold) parts.push(Text({ dimColor: true, hover: tip('ck-cr'), children: [` │ cr ${cold} ${coldUsd}`] }))
1059 const limits = allLimits()
1060 if (limits.length) parts.push(Text(limitTone(limits, { hover: tip('ck-limits'), children: [' │ ' + limitsText(limits, ' ')] })))
1061 // Whole segments wrap to a second row; a squeezed segment would split its label from its value
1062 const row = [Box({ flexDirection: 'row', flexWrap: 'wrap', children: parts })]
1063 if (st.kind === 'cooling' && big) {
1064 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') } }))
1065 } else if (st.kind === 'kept') {
1066 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') } }))
1067 }
1068 const left = Box({ flexDirection: 'row', columnGap: 2, children: row })
1069 // Right edge: one click runs /session-handoff (h while the band has the focus;
1070 // a letter never fires from the prompt box, unlike a digit). Once a handoff
1071 // is in, c clears this chat and sends it as the fresh chat's first prompt.
1072 const busy = handoffPending || H.armed
1073 const handoff = Button({ key: 'handoff', label: handoffPending ? 'handoff queued' : H.armed ? 'handoff running' : 'handoff', hotkey: 'h', plain: true, dimColor: busy, onPress: () => runHandoff($) })
1074 let right = handoff
1075 if (ready() || H.continuing) {
1076 const go = Button({ key: 'continue', label: H.continuing ? 'clearing' : 'clear and continue', hotkey: 'c', plain: true, onPress: () => clearAndContinue($) })
1077 right = Box({ flexDirection: 'row', columnGap: 2, children: [go, handoff] })
1078 }
1079 const mine = Box({ flexDirection: 'row', justifyContent: 'space-between', width: '100%', columnGap: 2, children: [left, right] })
1080 // Hover help rows sit above the band row, so revealing one leaves the hovered text in place.
1081 const help = (scope, text) => Box({ display: 'none', hover: { scope, display: 'flex' }, children: [Text({ dimColor: true, italic: true, children: [text] })] })
1082 const cacheHelp = { kept: `kept warm until ${clock(S.keepWarmUntil)}, ${S.pings} ping${S.pings === 1 ? '' : 's'} so far (${money(S.pingUsd)})`, warm: `warm, expires in ${minutes(st.left)}`, cooling: `cooling, expires in ${minutes(st.left)}`, cold: `cold for ${minutes(-st.left)}` }
1083 const tips = [
1084 help('ck-c', `c = prompt cache: ${cacheHelp[st.kind] || 'state unknown'}${ttl ? ` (${ttl}m TTL)` : ''}; once cold, the next send writes the whole context again`),
1085 help('ck-ctx', `ctx = context: ${tokens(S.ctx)} tokens in this session's context window${S.window ? ` of ${tokens(S.window)}` : ''}`),
1086 help('ck-rewrite', `rwc = rewrite cost: estimated cost to write ${tokens(S.ctx)} of context into the cache again if it expires (cache write price${ttl ? `, ${ttl}m TTL` : ''})`),
1087 help('ck-sc', 'sc = session cost: what this session has cost so far, at API list prices'),
1088 ]
1089 if (cold) tips.push(help('ck-cr', `cr = cold restarts: ${cold} send${cold === 1 ? '' : 's'} rewrote the expired cache this session, ${coldUsd} in total`))
1090 const resets = resetsText(limits, now)
1091 if (resets) tips.push(help('ck-limits', 'plan limits: ' + resets))
1092 return Box({ flexDirection: 'column', children: below ? [...tips, mine, below] : [...tips, mine] })
1093 })
1094
1095 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
1096 if (e.requestId !== PANE) return next(e)
1097 return drawBoard($, e)
1098 })
1099
1100 // The footer draws in the terminal and the Desktop app; it carries a short
1101 // label, only when there's something to act on.
1102 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
1103 const label = footerLabel()
1104 if (!label) return next(e)
1105 const modes = Array.isArray(e.props && e.props.modes) ? e.props.modes : []
1106 return next({ ...e, props: { ...e.props, modes: [...modes, label] } })
1107 })
1108}
1109
1110function footerLabel() {
1111 const parts = []
1112 const st = cacheState(myHeartbeat())
1113 const big = S.ctx >= settings.bigTokens
1114 if (st.kind === 'kept') parts.push('cache kept warm')
1115 else if (st.kind === 'cooling' && big) parts.push(`cache cools in ${minutes(st.left)} · /keepwarm`)
1116 else if (st.kind === 'cold' && big) parts.push(`cache cold · rewrite ≈ ${money(rewriteCost(S.ctx, S.model, ttlMin()))}`)
1117 const high = allLimits().filter((l) => (l.percentUsed || 0) >= 80)
1118 if (high.length) parts.push(limitsText(high))
1119 if (ready()) parts.push(`handoff ready · /${names.handoff} continue`)
1120 return parts.join(' · ')
1121}
1122
1123function sortFleet(list) {
1124 const rank = { waiting: 0, working: 1, idle: 2 }
1125 return [...list].sort((a, b) => {
1126 const r = (rank[a.state] ?? 3) - (rank[b.state] ?? 3)
1127 if (r !== 0) return r
1128 const la = msLeft(a.lastActivity, a.ttlMin) ?? Infinity
1129 const lb = msLeft(b.lastActivity, b.ttlMin) ?? Infinity
1130 return la - lb
1131 })
1132}
1133
1134function cacheCell(hb) {
1135 const st = cacheState(hb)
1136 if (st.kind === 'kept') return ['kept warm', 'cyan']
1137 if (st.kind === 'warm') return ['warm ' + minutes(st.left), 'green']
1138 if (st.kind === 'cooling') return ['cools in ' + minutes(st.left), 'yellow']
1139 if (st.kind === 'cold') return ['cold ' + minutes(-st.left), (hb.ctx || 0) >= settings.bigTokens ? 'red' : 'gray']
1140 return ['–', 'gray']
1141}
1142
1143function stateCell(hb) {
1144 if (hb.stale) return ['? silent', 'gray']
1145 if (hb.state === 'waiting') return ['⏳ waiting', 'magenta']
1146 if (hb.state === 'working') return ['● working', 'cyan']
1147 return ['○ idle', 'gray']
1148}
1149
1150// Text props for a colour name; 'gray' draws dim, which every theme has
1151function tone(color, extra = {}) {
1152 return color === 'gray' ? { ...extra, dimColor: true } : { ...extra, color }
1153}
1154
1155function boardText() {
1156 const rows = sortFleet(fleet).map((hb) => {
1157 const [state] = stateCell(hb)
1158 const [cache] = cacheCell(hb)
1159 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)' : ''}`
1160 })
1161 return ['Sessions on this machine:', ...rows].join('\n')
1162}
1163
1164function drawBoard($, e) {
1165 const { Box, Text, Button } = $.ui.resolve(e)
1166 const width = Math.max(60, (e.props && e.props.bodyColumns) || 100)
1167 const labelWidth = Math.max(16, Math.min(44, width - 62))
1168 const list = sortFleet(fleet)
1169 const header = Text({ dimColor: true, children: [`${pad('STATE', 11)} ${pad('SESSION', labelWidth + 1)} ${pad('CONTEXT', 8)} ${pad('CACHE', 14)} ${pad('COST', 7)} AGENTS`] })
1170 const rows = list.map((hb, i) => {
1171 const [state, stateColor] = stateCell(hb)
1172 const [cache, cacheColor] = cacheCell(hb)
1173 const label = clip(mask(hb.label || hb.id), labelWidth) + (hb.self ? ' ◂' : '')
1174 const ctx = tokens(hb.ctx) + (hb.window ? ' ' + Math.round((100 * (hb.ctx || 0)) / hb.window) + '%' : '')
1175 const cells = [
1176 Text(tone(stateColor, { children: [pad(state, 11) + ' '] })),
1177 Text({ bold: !!hb.self, children: [pad(label, labelWidth + 1) + ' '] }),
1178 Text({ children: [pad(ctx, 8) + ' '] }),
1179 Text(tone(cacheColor, { children: [pad(cache, 14) + ' '] })),
1180 Text({ children: [pad(money(hb.costUsd), 7) + ' '] }),
1181 Text({ dimColor: !hb.agentsRunning, children: [String(hb.agentsRunning || 0) + ' '] }),
1182 ]
1183 const big = (hb.ctx || 0) >= settings.bigTokens
1184 if (big && !hb.stale && i < 9) {
1185 cells.push(
1186 Button({
1187 key: 'kw-' + hb.id,
1188 label: hb.keepWarm ? 'stop warm' : 'keep warm',
1189 hotkey: String(i + 1),
1190 plain: true,
1191 dimColor: !hb.keepWarm && cacheState(hb).kind !== 'cooling',
1192 onPress: async () => {
1193 now = await $.clock.now()
1194 if (hb.self) {
1195 if (S.keepWarm) stopKeepWarm($, 'turned off')
1196 else startKeepWarm($, settings.keepWarmHours)
1197 await heartbeat($, true)
1198 } else {
1199 await $.store.set('cmd:' + hb.id, { keepWarm: !hb.keepWarm, at: now })
1200 $.ui.toast(`Sent to "${mask(hb.label)}". It applies within 30 seconds.`)hooks/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