SLOPSHOPPER

session-band

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

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

Draws

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

session-band

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

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

<img src="screenshots/band.png" alt="session-band above the prompt" width="686">

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

Why

Every request re-reads the whole chat. From the prompt cache that costs a tenth of the normal input price. Once the cache expires (5 minutes or 1 hour idle, depending on your plan), the next message writes the whole chat into the cache again at up to 2x the input price. On a large chat that one message can cost a few dollars.

Requirements

  • Claude Code 2.1.287 or later, in the terminal or the Desktop app's Code tab. The VS Code chat does not draw the band; the claude-status extension shows the same values in the VS Code status bar.
  • Mods turned on for your account. claude plugin test in any folder answers "no hooks module to load" when mods can load.
  • Do not install it next to Cache Keeper. Both register the same commands.

Install

/plugin marketplace add IT-BAER/claude-forge
/plugin install session-band@claude-forge

Start a new session afterwards.

The band

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

PartMeaning
c 60mPrompt cache state and minutes until it expires. Yellow 5 minutes before expiry, red once cold. While keep-warm runs: ◆ c kept 7:12pm, the time it stops.
ctxTokens in context.
rwcRewrite cost: what writing the context into the cache again would cost if it expires.
scSession cost so far, at API list prices.
crCold restarts: how many sends rewrote an expired cache this session, and what they cost. Shown only after the first one.
5h, w, fw5-hour, weekly and per-model weekly (fw for Fable, the model's initial) plan limits used. Yellow at 80 %, red at 95 %. The per-model windows come from the plan usage endpoint (/api/oauth/usage, asked at most every 5 minutes), because the mod API does not pass them.

Hover c, ctx, rwc, sc or cr for a one-line explanation above the band. Each starts with what the label stands for (rwc = rewrite cost: ...), and c names the state in full (kept, warm, cooling, cold). Hover the plan limits for what each window is and when it resets: 5h (5-hour window) resets 1:50pm (in 2h15m) · w (weekly, all models) resets Fri 9:00am (in 3d21h). On a subscription the prices show up as usage against your limits, not as a bill.

Commands

  • /keepwarm [hours|off] keeps this chat's cache warm, 4 hours by default, with a small ping shortly before it would expire.
  • Cold-send guard: a message into a chat over 150k tokens after its cache expired asks first: Send anyway, Compact first, or Cancel.
  • /board shows every local chat in one pane: waiting on you first, then working, then the ones whose cache cools soonest.
  • /handoff (or the handoff button) runs the bundled session-handoff skill, then clears the chat and starts the fresh one from the handoff.
  • /cache shows status and settings: ttl 5|60|auto, guard on|off, big 150k, alerts [on|off].
  • Alerts are off by default. They are the unasked toasts: a big cache about to go cold, and another chat waiting on you. /cache alerts toggles them.

Changes from Cache Keeper

  • Short band labels (c, rwc, sc, w) so the band fits next to the prompt, session cost before the plan limits.
  • Hover help for the short labels. The help row opens above the band, so the hovered text does not move.
  • Handoff files: when Claude writes a session-handoff.md, a restart-packet.md (also with a suffix, like session-handoff-pve.md) or a file under handoffs/, the band offers to clear and continue from that file as soon as it is written. Any header format works; only a header marked status: consumed (or another non-active status) is skipped. If a handoff turn ends on a short answer (a Stop hook added a tail), the last handoff file written in the session is used instead. The fresh chat gets a short "read this file" prompt instead of the whole text.
  • Renamed to session-band. Data lives in ~/.claude/mods-data/session-band/.
  • Earlier versions of this fork were called cache-status. To switch, run /plugin uninstall cache-status@claude-forge, then install session-band@claude-forge and restart each chat. Nothing reads ~/.claude/mods-data/cache-status/ any more, so you can delete that folder once no chat runs the old mod.

Where the data lives

Everything stays on your machine, under ~/.claude/mods-data/. The mod makes no network calls of its own. Keep-warm pings are small model requests, sent only while /keepwarm is on.

License

MIT. See LICENSE. Original work copyright Nate Herk.

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