SLOPSHOPPER

cache-keeper

Keeps a big chat's prompt cache warm, warns before an expensive cold restart, shows every local chat on one board (/board), and hands a chat off to a fresh one…

newpanebandspinnerguardcommand
v1.3.0MITupdated 2026-10-21bStyler/claude-mods/cache-keeper
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-keeper
│ ┃ Sessions ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ STATE SESSION CONTEXT CACHE │ cache-keeper │ │ ┃ 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-keeper: Cache Keeper: 97.4k tokens in context on opus-5-5, │ ⎿ cache-keeper: No request yet this session, so the cache state is │ ⎿ cache-keeper: Plan limits used: 5h 31%. │ ⎿ cache-keeper: Session cost so far: $0.42. Cold restarts this ses │ ⎿ cache-keeper: Cold-send guard: on for contexts over 150k tokens. │ ⎿ cache-keeper: Settings: /cache ttl 5|60|auto, /cache guard on|of │ │ Opus 5.5 │ ctx █████░░░░░ 49% 97.4k/200k │ rewrite ≈ $0.78 │ 5h 31% │ session $0.42 h: handoff ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Opus 5.5 │ ctx █████░░░░░ 49% 97.4k/200k │ rewrite ≈ $0.78 │ 5h 31% │ session $0.42 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

claude-mods

Four Claude Code mods, published as a plugin marketplace named claude-super-mods.

ModWhat you get
goal-meter 1.2.1A progress bar for /goal, built from Claude's own task plan. One line by default. /goals opens the full task list.
cache-keeper 1.3.0A band above the prompt with the model, effort, context bar, plan limits (with the 5h reset) and cache state, so no status line is needed. Keeps a long chat's prompt cache warm (/keepwarm 2), asks before an expensive cold send, shows every local chat (/board), and hands a chat off (/handoff). Every compaction without its own instructions is told what to keep, and the band shows how much it saved.
lean-explore 1.6.0Replaces the built-in Explore subagent (the one that does codebase searches) with a lean one: Haiku, low effort, Read/Grep/Glob only, short answers with file:line. It counts what it really did (searches, reads, tokens delegated to Haiku) from your own transcripts. `/lean-explore on\off\status\stats\rescan`.
memory-guard 0.2.2Spots sessions worth remembering (files written to output folders, MCP writes, meeting transcripts, decisions, commits, accepted plans, long chats) and nudges once to run /memorize. A session that ends without it is queued, and the next session in that project offers to catch up. Files into your wiki if you point it at one, else Claude Code's own auto-memory. Ships its own /memorize skill.

lean-explore

A plugin cannot replace a built-in agent with an agent of its own (plugin agents get a lean-explore: prefix). So on the first session the mod writes ~/.claude/agents/Explore.md itself, marked with a comment line so it never touches an Explore.md you wrote.

  • Desktop app: a chip above the prompt. Green Explore: Haiku · 6 searches counts this chat's Explore runs; hover it for a card with a Turn off button. Amber Explore: built-in means it is off, with a Turn on button. After a toggle it adds what the next session will use. It shares one row with the TL;DR button (the mods-row convention), chip on the left.
  • Terminal: a footer label, Explore: Haiku or Explore: built-in, with the same next-session note.
  • /lean-explore stats prints all-time searches, reads and tokens delegated to Haiku (measured from usage figures; the "kept out of chat" number is an estimate at 4 characters per token). /lean-explore rescan rebuilds the totals from your transcripts.
  • While the lean agent is on, the mod adds a short "Searching the codebase" note to the system prompt: delegate open-ended searches to Explore, read directly when the path is known. No CLAUDE.md edit needed.
  • /lean-explore status shows whether it is on, off, or blocked by your own Explore.md.
  • /lean-explore off renames it to Explore.md.off; the built-in Explore comes back.
  • /lean-explore on brings it back.
  • Agent files load when a session starts, so every change applies from the next session.
  • Before uninstalling, run /lean-explore off, or delete ~/.claude/agents/Explore.md. Uninstalling the mod does not remove the file.

When a search needs judgment (a review, a design call), ask for a general-purpose agent or pass model: "opus": Haiku finds things well but checks less of what it reads.

memory-guard

Each session scores a point per kind of durable work: a file written to an output folder (docs, reports, deliverables, notes), a write through any MCP tool, a meeting or transcript read, a decision in your prompt ("let's go with", "from now on"), a git commit, an accepted plan. Ten turns or more adds one. At 2 points it nudges once: a toast, a band above the prompt with Memorize now and Not this session, and /memorize as the prompt suggestion. A session that ends without /memorize goes on that project's queue. The next session there shows Catch up, and the hint line says · 3 unmemorized.

With no setup it files into Claude Code's auto-memory (~/.claude/projects/<project>/memory/ and its MEMORY.md). To use a wiki, or your own /memorize skill, add .claude/memory-guard.json to the project (or ~/.claude/memory-guard.json for all of them). Every field is optional:

{
  "kb": { "path": "wiki", "index": "wiki/index.md", "log": "wiki/log.md", "rules": "wiki/CLAUDE.md" },
  "skill": "memorize",
  "threshold": 2,
  "longSessionTurns": 10,
  "outputDirs": ["docs", "reports", "deliverables", "notes"],
  "signals": { "decision": false },
  "extraSignals": [{ "name": "Tickets", "tool": "^mcp__.*linear.*__(create|update)" }, { "name": "Release", "bash": "npm publish" }],
  "ui": { "band": true, "hint": true, "suggest": true }
}
  • kb: your knowledge base. The bundled skill reads its rules file and index, files pages there, and logs each one. A write to kb.log counts as memorized.
  • skill: the skill the buttons run. Leave it out to use the bundled memory-guard:memorize.
  • extraSignals: your own signals, by tool-name or Bash-command regex.

On desktop a chip above the prompt shows Memory guard: on · 1 of 2 (green) or off (grey); hover it for a Turn off here / Turn on here button. The terminal footer says memory guard on or off. "ui": { "chip": false } hides both.

If it finds a wiki/ folder or an Obsidian vault with no config, it says so once. The band and hint line show in the terminal and the desktop app; elsewhere the toast and /memorize-queue still work.

Credit

goal-meter and cache-keeper are built on Nate Herk's claude-code-mods (MIT). goal-meter is a fork of his goal-meter 1.0.0, and cache-keeper is based on his cache-keeper 1.0.0. Each mod's CHANGES.md lists what differs from upstream. cache-keeper 1.3.0 also borrows three ideas from JayGPTPro's Smart PreCompact. MIT, see LICENSE.

Install

Needs a Claude Code build with mods (function-hook plugins). Works on Windows, macOS and Linux.

claude plugin marketplace add bStyler/claude-mods
claude plugin install goal-meter@claude-super-mods
claude plugin install cache-keeper@claude-super-mods
claude plugin install lean-explore@claude-super-mods
claude plugin install memory-guard@claude-super-mods

Then quit Claude Code and start it again. /reload-plugins and /clear do not load mod code. Check: type /goals. It should open the goal meter pane.

If Nate Herk's nateherk-mods is installed on that machine, turn off its copies first (same names, same tools):

claude plugin disable goal-meter@nateherk-mods
claude plugin disable cache-keeper@nateherk-mods

cache-keeper's band replaces a custom status line. If you had one, remove the statusLine block from ~/.claude/settings.json so the same figures don't show twice.

Commands

goal-meter

CommandDoes
/goal <what done looks like>Starts a goal. Claude plans it as sized tasks and the bar tracks them.
/goalsFull task list, plus other chats' goals from the last 12 hours.
/goals compact / /goals fullOne-line bar (default) or the full bar. Saved for every chat.
/goals hide / /goals showHide the bar and footer label in every chat, or bring them back.
/goals clearRemove this chat's goal, running or finished.
/goals strict onNo file edits in a /goal until Claude has written the plan. Useful for coding.

The g key on the bar only works after Ctrl+X Tab focuses the bar. From the prompt, type /goals.

cache-keeper

CommandDoes
/keepwarm 2 / /keepwarm offKeep this chat's cache warm for N hours (max 24).
/cache big 100kSize at which it asks before a cold send (default 150k).
/cache guard offTurn the cold-send question off.
/cache ttl 5 / 60 / autoCache window, if it guesses wrong.
/boardEvery local chat on one board.
/handoffWrite a handoff and continue in a fresh chat.

memory-guard

CommandDoes
/memorizeSave what this session learned (bundled skill: memory-guard:memorize).
/memorize queueCatch up on the queued sessions from their transcripts.
/memorize-queue / /memorize-queue clearShow this session's score and the queue, or clear it.
/memory-guard off / on / statusStop or start watching this project; show where it files and what it counts.

Update

claude plugin marketplace update claude-super-mods
claude plugin update goal-meter@claude-super-mods
claude plugin update cache-keeper@claude-super-mods
claude plugin update lean-explore@claude-super-mods
claude plugin update memory-guard@claude-super-mods

Restart Claude Code afterwards. When changing code, raise version in the mod's .claude-plugin/plugin.json, or plugin update will not pick it up.

Uninstall

claude plugin uninstall goal-meter@claude-super-mods
claude plugin uninstall cache-keeper@claude-super-mods
claude plugin uninstall lean-explore@claude-super-mods
claude plugin uninstall memory-guard@claude-super-mods
claude plugin marketplace remove claude-super-mods

Saved state lives in ~/.claude/mods-data/ and can be deleted.

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