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
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 │ │ │ ctx 97.4k │ rewrite ≈ $0.78 │ 5h 31% │ session $0.42 h: handoff ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
│ ctx 97.4k │ 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

<h1 align="center">Awesome Claude Code Mods</h1>

<a id="showcase"></a>

Mod showcase

<table> <tr><td width="33%" align="center" valign="top"><p><a href="https://github.com/therahul-yo/clawdman"><img src="assets/showcase-01.gif" width="100%" alt="Clawdman: An animated companion that reacts to your coding session. Demo · 10×."></a></p><p><strong><a href="https://github.com/therahul-yo/clawdman">Clawdman</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/pi-agent/"><img src="assets/showcase-02.gif" width="100%" alt="Pi Agent: Run pi-powered models as native Claude Code subagents. Demo · 10×."></a></p><p><strong><a href="AgentMods/pi-agent/">Pi Agent</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="FocusMods/breathing-exercises/"><img src="assets/showcase-03.gif" width="100%" alt="Breathing Exercises: A guided breathing animation while Claude works."></a></p><p><strong><a href="FocusMods/breathing-exercises/">Breathing Exercises</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="sources/creative-toolkit/"><img src="assets/showcase-04.png" width="100%" alt="Creative Toolkit: A collection of live panels, session tools and playful mods. Code Pet screenshot."></a></p><p><strong><a href="sources/creative-toolkit/">Creative Toolkit</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/agent-cartoons/"><img src="assets/showcase-05.gif" width="100%" alt="Agent Cartoons: Coding activity becomes a cartoon in a choice of visual styles."></a></p><p><strong><a href="GameMods/agent-cartoons/">Agent Cartoons</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="https://github.com/adamholter/claude-subway-surfers"><img src="assets/showcase-06.gif" width="100%" alt="Subway Surfers Desktop: Gameplay beside Claude Desktop while it works. Desktop demo · 10×."></a></p><p><strong><a href="https://github.com/adamholter/claude-subway-surfers">Subway Surfers Desktop</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="GameMods/dino-game/"><img src="assets/showcase-07.gif" width="100%" alt="Dino Game: Play the T-rex runner above your prompt."></a></p><p><strong><a href="GameMods/dino-game/">Dino Game</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/spell-bar/"><img src="assets/showcase-08.gif" width="100%" alt="Spell Bar: An animated spell bar for your selected effort level."></a></p><p><strong><a href="GameMods/spell-bar/">Spell Bar</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/agent-dashboard/"><img src="assets/showcase-09.gif" width="100%" alt="Agent Dashboard: A live dashboard for context, costs, permissions and agents."></a></p><p><strong><a href="AgentMods/agent-dashboard/">Agent Dashboard</a></strong></p></td></tr> </table>

<a id="browse-by-category"></a>

<h2>Browse by category <img src="assets/category-magnifier.gif" width="52" height="40" alt="Clawd with a magnifying glass"></h2>

Usage Mods · Agent Mods · Planning Mods · File Mods · Git Mods · Safety Mods · Memory Mods · Interface Mods · Prompt Mods · Testing Mods · Web Mods · Focus Mods · Game Mods · Integration Mods

Source packages available here: 82 mods from 38 repositories, with their complete tracked files and notices. Local package links open inside this collection. Other entries currently lead to upstream references while the wider source import is reviewed.

Entries are grouped by their main purpose. Collections can contain several kinds of mods; hosted mod pages are categorized individually.

<a id="usage"></a>

Usage Mods

Browse Usage Mods

Track model usage, costs, effort and quotas.

  • Auto Effort · arthur-fontaine - Let Jev select Claude's reasoning effort for each prompt, adapting the configured effort level to the incoming request rather than choosing it manually every time.
  • Automatic Model and Effort Router · sofanaja44 - Choose a Claude model and reasoning effort according to task difficulty, with rules that limit unnecessary prompt-cache rewrites. Cost and cache statistics help you follow the routing decisions.
  • Braille Usage · kawase1295 - Check your current model, context usage and five-hour or weekly limits in compact text-based meters above the prompt.
  • Budget and Context Toolkit · Arunjay4213 - Track session spending, subscription quotas, cache use and per-turn token costs. Set budget limits that warn before exhaustion and stop tool calls when a configured limit is exceeded.
  • Budget Governor · ccdwyer - Set session and daily spending caps, monitor them above the prompt, and receive a wrap-up reminder at eighty percent. New prompts are refused once the cap is reached.
  • Cache, Context and Cost Panel · anthonyhungnguyen - Track token spending for the session and recent days, get context-limit warnings, and see what rebuilding the prompt cache cost. A completion alert announces long turns.
  • Clawd Usage Meters · Toshkee - Watch live context, rate-limit and cost meters above the prompt, accompanied by Clawd's wand animations and banner tricks as you work in Claude Code.
  • Cockpit and Bodyguard · Para-FR - Inspect session turns, tools, tokens, durations and changed files in a live cockpit. A companion guard blocks access to .env files and destructive Bash commands, displaying blocked-action alerts.
  • Context and Quota Forecast · imsalik - See a context forecast alongside five-hour and weekly rate-limit usage above the prompt. Reset countdowns help you understand when each plan allowance becomes available again.
  • Context Saver · AlmogBaku - Spot repeated tool calls and wasteful workflows during a Claude Code session. Review their measured time and context cost, then send a suggested correction with one click.
  • Context Weather and Usage · travisoa - Read context fullness as changing weather symbols alongside token totals and quota consumption. Recent-turn bars show how the conversation grew, helping you spot increasing pressure on the context window.
  • Cost and Output Token Band · Luma-Sa - Keep five-hour and seven-day usage limits, session cost and output-token totals visible in a band above the Claude Code prompt while you work.
  • Cost Meter · zaferayan - Track API-equivalent session cost above the prompt, including subagent usage, with a configurable budget warning. On subscription plans, the displayed amount estimates API pricing rather than your bill.
  • Desktop Next Steps and Limits · shichang4fun - See quota limits and reset countdowns above the prompt in Claude Code Desktop. After each turn, suggested next prompts can be sent with a click or copied for editing.
  • Desktop Session Status Bar · sgmonda - Keep five-hour and weekly usage limits, reset times, session clocks and turn duration visible in Claude Code Desktop. The bar also lists active subagents, background jobs and scheduled wakeups.
  • Desktop SVG Usage Meters · s-hiraoku - See context use and the five-hour and seven-day quota windows as SVG meters above the prompt in Claude Code Desktop. The compact display keeps capacity visible while you work.
  • Effort Guard · stefanochieli - Track context and token usage, review an effort log for each turn and receive an escalation alert after repeated command or test failures. It observes activity without blocking it.
  • Effort Router · totally-tim - Choose reasoning effort for each turn with Jev or a compatible classifier, and display the selected level. Shadow mode records the recommendation; enforce mode applies it to the session.
  • Effort Shortcut · daanqq - Cycle Claude's reasoning effort through low, medium, high, and extra-high with one command. Bind that command to a key to change effort quickly while skipping the maximum setting.
  • Focus Band · chaoshengsc - Keep your five-hour and weekly plan usage visible above the prompt. Progress bars, percentages, and reset times help you see how much of each allowance you have used.
  • Jet Router · jetsongdev - Experiment with per-turn reasoning effort recommendations while keeping your chosen model. Use fixed test decisions or opt into Jev classification, with observation and optional application modes.
  • Jev Claude Router · Flam1ngFir3ball - Let Jev choose the model and reasoning effort for each turn while considering the cost of switching. It also provides Jev-guided context compaction.
  • Jev Model and Effort Selection · unclecode - Observe or pin model and effort choices, or opt into automatic selection with Jev. The experimental router lets you compare recommendations before allowing them to steer the session.
  • Jev Model Router · satviksinha - Choose a model and reasoning effort for each turn using TypeSafe's Jev, through its direct API or Vercel AI Gateway. A visible routing decision shows which model will handle the work.
  • Jev Route · drewpayment - Choose a Claude model for each turn using Jev's estimate of what the task needs. Toggle routing with /route to balance model capability against cost.
  • Limitpace · fstandhartinger - Track subscription limits and spending pace through live usage bars. An optional delegation adviser can suggest using another provider as part of managing your available capacity.
  • Model and Effort Router · moritalous - Choose reasoning effort for each prompt while keeping the model fixed, or enable a separate router that selects Haiku, Sonnet, or Opus using Jev. Both tools adjust resources per turn.
  • Model Shortcuts · richkuo - Use keyboard shortcuts to switch models and adjust reasoning effort, with the active choices displayed in the prompt footer.
  • Netrunner HUD · ccdwyer - Watch context use, token activity, tool calls, costs, and job status in an animated cyberpunk dashboard. Git and test widgets keep related session information alongside the live usage gauges.
  • Next Steps and Usage Band · pawandeepdhall - Keep five-hour and weekly usage limits visible with reset countdowns, and choose from suggested next steps after each reply. Buttons also provide shortcuts for starting a new chat or pushing work.
  • Oxide Jev Model Router · Kiy-K - Use Jev to choose subagent models and adjust the main conversation's model and reasoning effort as work progresses. A built-in classifier provides a fallback without an API key.
  • Plan and Context Usage Line · KhadeerBasha1232 - Track five-hour and weekly plan limits, reset times, context usage, the active model and session cost in one line above the Claude Code prompt.
  • Plan Usage and Reset Times · jumoog - Keep five-hour and weekly plan usage visible above the prompt, together with each reset time. Check the current allowance windows without opening a separate usage screen.
  • Power Bottom Status Line · brianSchanbacher - See the model, folder, branch, context fill, cost, elapsed time, cache hits and live agents in a cross-platform status line at the bottom of Claude Code.
  • Project Spend and Cache Status · Sma1lboy - Track project spending alongside context usage, cache hits and the cache countdown. Inspect why a turn missed the cache, and open the project ledger with /usage.
  • Prompt Enhancement and Telemetry · MiguelMachado-dev - Inspect per-turn duration, first-token latency, token throughput, requests and stalls in a live band. An additional command rewrites a draft prompt with Sonnet and returns it for your review.
  • Prompt Footer Usage · WoBok - Keep context usage and rate-limit consumption visible in Claude Code's prompt footer, so you can check those readings without opening a separate usage view.
  • Prompt Usage Timeline · augiefra - Compare five-hour and weekly usage with elapsed time, inspect token bars for recent prompts and watch context status above the prompt. Labels are available in English or French.
  • Quota and Cost Pills · Sh0ckWaveZero - Track five-hour and seven-day rate limits, session tokens and cost in compact indicators above the prompt. The band can sit alongside other status bands.
  • Quota Reserve · chrisns - Reserve part of your five-hour and weekly quota for later work. The mod pauses at configured thresholds, asks whether to continue, and releases the reserve shortly before the reset.
  • Rate-Limit History Band · mohammed-alsalhi - Follow rate-limit consumption since each window last reset, including five-hour, weekly, and per-model weekly allowances. A band above the prompt graphs how usage accumulates over time.
  • Retro Usage Band · iamkhalid2 - Keep your plan's five-hour and weekly usage limits in view through a slim retro-style band above the prompt. Check both allowances as you work.
  • Sessclone · NotTahaAli - Send Claude Code usage to a SessClone deployment so laptop, cloud and CI sessions can contribute to a shared team ledger of usage and cost.
  • Session and Weekly Usage Band · Gooner44 - See session, weekly and Fable 5.1 usage together in a band above the Claude Code prompt, keeping these separate usage readings visible while you work.
  • Session Cockpit · nvr0x5 - Track context, spending, usage limits, reset countdowns and burn rates above the prompt. Plan progress and active subagents share the same cockpit, with display options for terminal and Desktop sessions.
  • Session Cost and Remaining Usage · HydrowZer - See the current session cost and remaining usage in a band above the Claude Code prompt, keeping spending and available capacity visible beside your conversation.
  • Session Dashboard and Alerts · lucenity0 - Follow turn durations, token counts, files touched, and tool activity through a timer and dashboard. Companion mods notify you when long turns finish and block destructive commands or edits to secrets.
  • Session Internals Dashboard · tomstagl - Inspect context, tokens, cost, limits, tools and agents in a live side-pane dashboard. An accompanying insights interface lets the session answer questions using the displayed measurements.
  • Session Sidebar · richard-parayno - Toggle a sidebar showing usage limits, context size, prompt-cache expiry, current activity and touched files. It keeps the session's resource picture visible beside the conversation.
  • Stack and Wait Time · ShahriarBijoy - Follow tasks, subagents, rate limits and monthly cost in a stacked progress band. A companion vocabulary game uses German words related to the session's topic while Claude works.
  • Terminal Usage Monitor · ilkayGkbdk - Monitor context fill, rate limits, token speed, cost, and active subagents in a live terminal dashboard. The display brings the session's resource use together in one place.
  • Turn Receipts and Error Cues · roaringsoul404 - Receive a receipt after each turn listing touched files, commands, tokens and cost. A companion animation turns the spinner into a pixel-art failure cue when a tool call fails.
  • Usage and Active Agent Dashboard · quango2304 - Follow usage bars, limits, session cost, cache expiry and active time alongside the agents currently running. The combined display makes resource consumption visible while work is underway.
  • Usage and Cache Warmth Band · Vosssa - See five-hour and weekly usage, context fill and prompt-cache warmth together above the Claude Code prompt, keeping the main resource readings visible while you work.
  • Usage and Spend Chips · kreddevils18 - Track five-hour and weekly allowances, session cost, and spending history through colored chips above the prompt. Open /usage-mod for a fuller view of the usage details.
  • Usage Line · Steven-ODell - See five-hour, weekly and context usage in the prompt footer, including whether consumption is ahead of or behind an even pace through the usage window.
  • Usage Log · tanuu5 - Record five-hour and weekly usage after each turn, then compare consumption with your chosen pace in a graph. The separate Clawd Dance mod can also show that pacing above the prompt.
  • Usage Report · Schweem - Monitor context usage, five-hour and weekly quotas, and session cost in the status line, with warnings as you approach your limits.
  • Usage Wrapup · DaKev - Ask running agents to wrap up when your remaining plan allowance gets low, helping them finish their current work before the usage limit interrupts it.
  • Weekly Usage and Spend Board · aycandv - Compare spending by model with a weekly-limit pace gauge, using a sliding ticker in the terminal or a strip and details pane in the desktop app.

<a id="agents"></a>

Agent Mods

Browse Agent Mods

Coordinate agents and follow their actual work.

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