SLOPSHOPPER

cache-status

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…

newpanebandspinnerguardcommand
★ 1v?MITupdated 2026-10-09IT-BAER/claude-forge/mods/cache-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-status
│ ┃ Sessions ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ STATE SESSION CONTEXT CACHE │ cache-status │ │ ┃ COST AGENTS ⏺ Read(src/auth.ts) │ Keeping this cache warm until 12:53pm. A │ │ ┃ ○ idle app · fix the f… 97.4k 49 – ⎿ Read 6 lines │ ping reads the cache before it expires. │ │ ┃ └ warm until 12:53pm, 0 ping(s) ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ $0 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ 1 session · $0.42 total · 0 waiting on you · ⎿ 3 pass, 1 fail │ ┃ cold restarts $0 · keep warm reads the cache │ ┃ before it expires · Esc closes ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cache │ ⎿ cache-status: Cache Status: 97.4k tokens in context on opus-5-5, │ ⎿ cache-status: No request yet this session, so the cache state is │ ⎿ cache-status: Plan limits used: 5h 31%. │ ⎿ cache-status: Session cost so far: $0.42. Cold restarts this ses │ ⎿ cache-status: Cold-send guard: on for contexts over 150k tokens. │ ⎿ cache-status: Settings: /cache ttl 5|60|auto, /cache guard on|of │ │ │ ctx 97.4k │ rwc ≈ $0.78 │ sc $0.42 │ 5h 31% h: handoff ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
│ ctx 97.4k │ rwc ≈ $0.78 │ sc $0.42 │ 5h 31% h: handoff ⟨Claude Code's own drawing⟩
Pane · Sessions
STATE SESSION CONTEXT CACHE COST AGENTS ○ idle app · fix the f… 97.4k 49 – $0.42 └ warm until 12:53pm, 0 ping(s) $0 1 session · $0.42 total · 0 waiting on you · cold restarts $0 · keep warm reads the cache before it expires · Esc closes
README

cache-status

A compact prompt-cache status band for Claude Code, with a cold-send guard, keep-warm, a board of all local chats, and handoffs to a fresh chat.

This is a fork of Cache Keeper by Nate Herk (nateherkai/claude-code-mods), MIT licensed. The cache logic, board, guard and handoff flow are his work. See Changes from Cache Keeper for what this fork adds.

<img src="screenshots/band.png" alt="cache-status band above the prompt" width="640">

The band above the prompt: cache warm for 60 minutes, 342k tokens in context, a cold rewrite would cost about $2.73, the session cost $13 so far, and the 5-hour and weekly plan limits.

Why

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.

Requirements

  • Claude Code 2.1.287 or later, in the terminal or the Desktop app's Code tab.
  • Mods turned on for your account. claude plugin test in any folder answers "no hooks module to load" when mods can load.
  • Do not install it next to Cache Keeper. Both register the same commands.

Install

/plugin marketplace add IT-BAER/claude-forge
/plugin install cache-status@claude-forge

Start a new session afterwards.

The band

● c 60m │ ctx 342k │ rwc ≈ $2.73 │ sc $13 │ 5h 84% · w 24%

PartMeaning
c 60mPrompt cache state and minutes until it expires. Yellow 5 minutes before expiry, red once cold.
ctxTokens in context.
rwcRewrite cost: what writing the context into the cache again would cost if it expires.
scSession cost so far, at API list prices.
5h, w5-hour and weekly plan limits used. Yellow at 80 %, red at 95 %.

Hover c, rwc or sc for a one-line explanation above the band. On a subscription the prices show up as usage against your limits, not as a bill.

Commands

  • /keepwarm [hours|off] keeps this chat's cache warm, 4 hours by default, with a small ping shortly before it would expire.
  • Cold-send guard: a message into a chat over 150k tokens after its cache expired asks first: Send anyway, Compact first, or Cancel.
  • /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].
  • Alerts are off by default. They are the unasked toasts: a big cache about to go cold, and another chat waiting on you. /cache alerts toggles them.

Changes from Cache Keeper

  • Short band labels (c, rwc, sc, w) so the band fits next to the prompt, session cost before the plan limits.
  • Hover help for the short labels. The help row opens above the band, so the hovered text does not move.
  • Handoff files: when Claude writes a session-handoff.md, a restart-packet.md (also with a suffix, like session-handoff-pve.md) or a file under handoffs/, the band offers to clear and continue from that file. Any header format works; only a header marked status: consumed (or another non-active status) is skipped. If a handoff turn ends on a short answer (a Stop hook added a tail), the last handoff file written in the session is used instead. The fresh chat gets a short "read this file" prompt instead of the whole text.
  • Renamed to cache-status. Data lives in ~/.claude/mods-data/cache-status/.

Where the data lives

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.

License

MIT. See LICENSE. Original work copyright Nate Herk.

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