SLOPSHOPPER

collision-guard

Asks before Claude edits a file another open chat changed in the last 30 minutes: Proceed, Move to a worktree, or Cancel (/guard)

newguardcommandpromptprocesstimer
★ 8v1.0.0MITupdated 2026-10-02nateherkai/claude-code-mods/collision-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · collision-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /guard ⎿ collision-guard: Collision Guard is on. Before an edit, it asks when another open chat changed the same file in the last 30 min ⎿ collision-guard: No other open chat has edited a file in that window. ⎿ collision-guard: This chat changed 3 files: auth.ts, audit.ts, cache.ts. ⎿ collision-guard: This chat so far: asked 0, proceeded 0, moved to a worktree 0, cancelled 0. ⎿ collision-guard: Settings: /guard on|off, /guard window <minutes>. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code mods

Four mods for Claude Code by Nate Herk. Mods are plugins that run inside Claude Code: they can draw on screen (a line above the prompt, a footer label, a side pane), add slash commands, and step in before Claude runs a tool. These four save money on big chats, keep private things off screen while you record, show how far along a /goal is, and stop two chats from editing the same file.

ModCommandsWhat it does
Cache Keeper/cache, /keepwarm, /board, /handoffShows whether a chat's prompt cache is warm or cold and what a cold restart would cost, keeps big chats warm, asks before an expensive cold send, shows every local chat on one board, and hands a chat off to a fresh one
Recording Mode/rec, /rec strict, /rec off, /rec configMasks keys, personal details, and business figures on screen and keeps private files closed while you record
Goal Meter/goalsA progress bar for /goal built from Claude's own task plan, with an ETA and every chat's goal in one pane
Collision Guard/guardAsks before Claude edits a file another open chat changed in the last 30 minutes

Requirements

  • Claude Code 2.1.287 or later. Check with claude --version. The mods work in the terminal and in the Desktop app's Code tab.
  • Mods turned on for your account. Anthropic is turning mods on gradually. Run claude plugin test in any folder: "no hooks module to load" means mods can load, and "served off" means they can't yet. Nothing needs reinstalling once they're on.

Install

Add this repo as a plugin marketplace:

claude plugin marketplace add nateherkai/claude-code-mods

Then install the mods you want, one command each:

claude plugin install cache-keeper@nateherk-mods
claude plugin install recording-mode@nateherk-mods
claude plugin install goal-meter@nateherk-mods
claude plugin install collision-guard@nateherk-mods

Then open a new chat. A mod loads when a chat starts, and /reload-plugins does not reload it.

Update: run claude plugin marketplace update nateherk-mods, then claude plugin update <mod>@nateherk-mods, then open a new chat. Installs are copies, so nothing changes until you update.

Remove: claude plugin uninstall <mod>@nateherk-mods.

Cache Keeper

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

  • Line above the prompt: ● cache warm 42m │ ctx 412k │ rewrite ≈ $3.30 │ session $5.12. It turns yellow 5 minutes before the cache expires and shows a keep warm button. Your 5-hour and weekly plan limits show there too, yellow at 80% and red at 95%.
  • /keepwarm [hours|off]: keeps this chat's cache warm, 4 hours by default, with a small ping shortly before it would expire. If a ping writes to the cache instead of reading it, keep warm turns itself off and tells you.
  • Cold-send guard: if you send a message into a chat over 150k tokens after its cache expired, it asks first: Send anyway, Compact first, or Cancel.
  • /board: every local chat in one pane, with chats waiting on you first, then working ones, then the ones whose cache cools soonest. Each row shows state, context size, cache, cost, and running agents, with a keep-warm button.
  • Handoff: the handoff button (or /handoff) runs the bundled /session-handoff skill, saves the handoff, then clears the chat and sends the handoff as the fresh chat's first prompt. A short fresh chat is far cheaper than a huge one.
  • /cache shows status and settings: ttl 5|60|auto, guard on|off, big 150k, alerts on|off.

Prices come from the Claude API price list. On a subscription plan they show up as usage against your limits, not a bill.

Recording Mode

Run /rec before you hit record. It applies to every open chat within 3 seconds, and /rec off turns it off everywhere.

  • Masks on screen: API keys and tokens, every value in a .env file in the project folder or its parents, emails, names, phone numbers, street addresses, card, account, and ID numbers, money, and figures next to business words (revenue, margin, payroll, and so on). /rec strict also masks every large figure and percentage.
  • Hides whole results from mail, chat, task, calendar, file, CRM, and finance tools. Claude still gets the real result; only the screen changes.
  • Keeps files closed: .env files, credentials, SSH and cloud keys, Claude's memory folder, and finance documents (payroll, invoices, contracts, budgets, forecasts, tax files). Finance tools like QuickBooks stay closed too. Claude is told it can't open them while recording.
  • Tells Claude: each prompt carries a hidden note to keep names, figures, and internal plans out of its replies.
  • A red ● REC shows above the prompt and in the footer while it's on.

Make it yours with /rec config

/rec config creates ~/.claude/mods-data/recording-mode/config.json:

{
  "keepNames": ["Your Name"],
  "names": ["A Teammate", "A Client"],
  "privatePaths": ["clients/", "finance/", "my-private-notes.md"],
  "businessTools": [],
  "closedTools": []
}
  • keepNames: your own name (and your brand), shown even while recording.
  • names: people whose names are always masked. Names in emails and contact fields are picked up on their own.
  • privatePaths: folders or file names Claude may not open while recording. Any part of a path matches, in any case.
  • businessTools: extra tool or command names whose results draw as hidden.
  • closedTools: extra tool names Claude may not call while recording.

Save the file and run /rec again.

Limits: it changes what is drawn on screen and what Claude may open, not what is stored. Pattern masking can't catch everything written in plain words, so watch your footage before you publish. The chat titles in the Desktop app's sidebar can't be masked by a mod.

Goal Meter

Type /goal <what done looks like> as usual. Claude first writes its plan with the goal meter's task tool, every task sized S, M, or L, then marks each task started and done as it works.

  • Line above the prompt: 5 of 8 tasks · 69%, a progress bar, 38m elapsed · about 17m left (≈3:40pm) · plan grew 7 → 8 · 1 agent running, the last tasks done, what's running and which agent runs it, the next tasks, and the goal check's last "not met yet" reason. Press g for the full list.
  • Footer: ◎ goal 69% · ~17m, visible even when the line above the prompt is hidden.
  • Percent and ETA: the percentage is finished work out of the plan, with S, M, and L tasks counting 1, 2, and 3. The ETA uses this goal's own pace so far and appears once 2 tasks are done. When Claude finds more work, the plan grows and the bar can step back.
  • /goals: this chat's whole task list, plus every other chat's goal from the last 12 hours.
  • Settings: /goals hide|show (the line above the prompt), /goals strict on (no file edits in a goal before the plan exists), /goals clear.

It sends no extra requests to Claude. The task tool calls Claude makes are a few small ones per goal. A plan Claude makes without /goal shows the same way.

Collision Guard

When you run several chats in one project, two of them can end up changing the same file without knowing it. Before Claude edits or writes a file, Collision Guard checks whether another open chat changed that file in the last 30 minutes. If one did, it asks:

  • Proceed: edit anyway.
  • Move to a worktree: Claude moves this chat's work into a git worktree so the two chats stop touching the same files (offered when git tracks the file).
  • Cancel: Claude leaves the file alone and asks you how to go on.

/guard shows what it sees, /guard off turns it off, and /guard window 60 changes the 30-minute window. Each chat keeps a small list of the files it changed, and nothing is added to Claude's prompt.

Where the data lives

Everything stays on your machine, under ~/.claude/mods-data/: Cache Keeper's session heartbeats and saved handoffs, the recording flag and config, each chat's goal plan, and Collision Guard's file lists. The mods make no network calls of their own. Cache Keeper's keep-warm pings are small model requests, sent only while /keepwarm is on.

License

MIT. See LICENSE.

Source 5 files
hooks/register.mjs 245 lines
1// Collision Guard: before Claude edits or writes a file, it checks whether
2// another open chat changed that file in the last 30 minutes. If one did, it
3// asks in the engine's own question dialog: Proceed, Move to a worktree (only
4// when the file is tracked in git, since a worktree carries tracked files
5// only), or Cancel. The choice goes back to Claude as the edit's result.
6//
7// Why: with several chats open in one repo, two of them can end up changing the
8// same file without knowing about each other.
9// Zero tokens: each chat keeps a small ledger of the files it changed, one file
10// per chat under ~/.claude/mods-data/collision-guard/, and reads the others'
11// before each edit. Nothing is added to the prompt.
12
13import { normPath, chatName } from './coach.mjs'
14import { clip, basename, minutes } from './fmt.mjs'
15import { makeMasker } from './privacy.mjs'
16
17const MIN = 60000
18const DIR = '/.claude/mods-data/collision-guard'
19
20const settings = {
21  on: true,
22  windowMin: 30, // another chat's edit this recent counts
23  liveMin: 15, // a chat whose ledger hasn't moved this long is closed
24  ignore: ['/.claude/projects/', '/.claude/mods-data/'], // memory and mod data: many chats write these on purpose
25}
26
27// This chat's ledger
28const S = { id: '', cwd: '', title: '', files: {} }
29let home = ''
30let dir = ''
31let now = 0
32let commandName = 'guard'
33let wrote = false
34let rec = { on: false, strict: false }
35let mask = (s) => s
36// "key|otherChat" -> the other chat's edit you said Proceed to; a newer edit asks again
37const allowed = new Map()
38const stats = { asked: 0, proceeded: 0, worktree: 0, cancelled: 0 }
39
40function own() {
41  return `${dir}/${S.id}.json`
42}
43
44async function writeOwn($, ended) {
45  if (!S.id || !dir) return
46  for (const [key, f] of Object.entries(S.files)) if (now - f.at > 2 * settings.windowMin * MIN) delete S.files[key]
47  if (!wrote && !ended && Object.keys(S.files).length === 0) return // a chat that never edits leaves no file
48  try {
49    await $.fs.write(own(), JSON.stringify({ id: S.id, title: S.title, cwd: S.cwd, updatedAt: now, ended: !!ended, files: ended ? {} : S.files }))
50    wrote = true
51  } catch {
52    // a locked file: the next edit or heartbeat writes it again
53  }
54}
55
56async function readOthers($) {
57  const out = []
58  let entries = []
59  try {
60    entries = await $.fs.list(dir)
61  } catch {
62    return out
63  }
64  for (const entry of entries) {
65    if (!entry.name.endsWith('.json') || entry.name === S.id + '.json') continue
66    try {
67      const v = JSON.parse(await $.fs.read(`${dir}/${entry.name}`))
68      if (!v || v.ended || now - (v.updatedAt || 0) > settings.liveMin * MIN) continue
69      out.push(v)
70    } catch {
71      // half-written or gone: skip it this time
72    }
73  }
74  return out
75}
76
77async function readRecording($) {
78  try {
79    const p = home + '/.claude/mods-data/recording.json'
80    if (!(await $.fs.exists(p))) rec = { on: false, strict: false }
81    else {
82      const flag = JSON.parse(await $.fs.read(p))
83      rec = { on: !!flag.on, strict: !!flag.on && !!flag.strict }
84    }
85  } catch {
86    rec = { on: false, strict: false }
87  }
88  mask = rec.on ? makeMasker({ strict: rec.strict }) : (s) => s
89}
90
91// The most recent edit of this file by another open chat, unless you already said Proceed to it
92function collisionFor(key, others) {
93  let best = null
94  for (const o of others) {
95    const f = o.files && o.files[key]
96    if (!f || now - f.at > settings.windowMin * MIN) continue
97    if ((allowed.get(key + '|' + o.id) || 0) >= f.at) continue
98    if (!best || f.at > best.at) best = { other: o, at: f.at }
99  }
100  return best
101}
102
103function agoText(ms) {
104  return ms < MIN ? 'just now' : `${minutes(ms)} ago`
105}
106
107// A worktree checks out tracked files only, so it helps only when git tracks this one
108async function isTracked($, raw) {
109  try {
110    const r = await $.process.run(['git', '-C', S.cwd, 'ls-files', '--error-unmatch', '--', raw], { timeoutMs: 5000 })
111    return r.exitCode === 0
112  } catch {
113    return false
114  }
115}
116
117async function decide($, raw, key, hit) {
118  const file = basename(raw)
119  const when = agoText(now - hit.at)
120  const surfaces = await $.session.surfaces()
121  if (!surfaces.length) {
122    // nobody to ask (a -p run): let it through rather than break an unattended job
123    $.ui.log(`collision-guard: another chat edited ${file} ${when}; nobody to ask, so the edit went ahead`, { to: 'debug' })
124    return null
125  }
126  const options = ['Proceed']
127  if (await isTracked($, raw)) options.push('Move to a worktree')
128  options.push('Cancel')
129  stats.asked += 1
130  let answer = 'Cancel'
131  try {
132    const who = rec.on ? 'Another open chat' : `Another open chat, "${clip(mask(chatName(hit.other)), 60)}",`
133    answer = await $.ui.ask(`${who} edited ${file} ${when}. Edit it here too?`, { options, header: 'Collision' })
134  } catch {
135    answer = 'Cancel' // dismissed
136  }
137  const what = `another open Claude Code chat ("${clip(chatName(hit.other), 80)}") edited ${raw} ${when}`
138  if (answer === 'Proceed') {
139    allowed.set(key + '|' + hit.other.id, hit.at)
140    stats.proceeded += 1
141    return null
142  }
143  if (answer === 'Move to a worktree') {
144    stats.worktree += 1
145    return { deny: `Not edited: ${what}. The user wants this chat's work moved into a git worktree so the two chats stop changing the same files. First list the files you already changed in this checkout (they stay here), then use the EnterWorktree tool and make this change inside the worktree.` }
146  }
147  stats.cancelled += 1
148  if (answer === 'Cancel') {
149    return { deny: `Not edited: ${what}, and the user cancelled this edit. Don't change this file again. Tell the user what you were about to change and ask how to proceed.` }
150  }
151  // text typed under "Other"
152  return { deny: `Not edited: ${what}. The user answered: "${clip(answer, 400)}". Follow that before touching this file again.` }
153}
154
155async function statusText($) {
156  now = await $.clock.now()
157  const others = await readOthers($)
158  const lines = [`Collision Guard is ${settings.on ? 'on' : 'off'}. Before an edit, it asks when another open chat changed the same file in the last ${settings.windowMin} minutes.`]
159  const busy = others
160    .map((o) => ({ o, n: Object.values(o.files || {}).filter((f) => now - f.at <= settings.windowMin * MIN).length }))
161    .filter((x) => x.n > 0)
162  if (busy.length) {
163    lines.push(`Other open chats with recent edits: ${busy.map((x) => `${rec.on ? 'a chat' : `"${clip(mask(chatName(x.o)), 40)}"`} (${x.n} file${x.n === 1 ? '' : 's'})`).join(', ')}.`)
164  } else lines.push('No other open chat has edited a file in that window.')
165  const mine = Object.values(S.files).filter((f) => now - f.at <= settings.windowMin * MIN)
166  lines.push(mine.length ? `This chat changed ${mine.length} file${mine.length === 1 ? '' : 's'}: ${mine.slice(-5).map((f) => basename(f.path)).join(', ')}.` : 'This chat has not changed a file in that window.')
167  lines.push(`This chat so far: asked ${stats.asked}, proceeded ${stats.proceeded}, moved to a worktree ${stats.worktree}, cancelled ${stats.cancelled}.`)
168  lines.push(`Settings: /${commandName} on|off, /${commandName} window <minutes>.`)
169  return lines.join('\n')
170}
171
172export function register(on) {
173  on('session.start', async ($, e, next) => {
174    now = await $.clock.now()
175    home = ((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '').replace(/\\/g, '/')
176    dir = home + DIR
177    S.id = await $.session.id()
178    S.cwd = await $.session.cwd()
179    const saved = await $.store.get('settings')
180    if (saved && typeof saved === 'object') Object.assign(settings, saved)
181    for (const name of ['guard', 'collision-guard']) {
182      try {
183        await $.command.register({ name, description: 'Collision Guard: asks before editing a file another open chat just changed', argumentHint: '[on|off|window <minutes>]', immediate: true })
184        commandName = name
185        break
186      } catch {
187        // taken: try the next name
188      }
189    }
190    // the ledger's heartbeat: other chats treat a ledger quiet for 15 minutes as closed
191    $.clock.every(60000, async () => {
192      now = await $.clock.now()
193      await writeOwn($)
194    })
195    return next(e)
196  })
197
198  on('session.end', async ($, e, next) => {
199    now = await $.clock.now()
200    if (wrote) await writeOwn($, true)
201    return next(e)
202  })
203
204  // The first prompt names this chat in other chats' questions
205  on('prompt.submit', async ($, e, next) => {
206    if (!S.title && e.origin && ['composer', 'bridge', 'sdk'].includes(e.origin.kind) && e.text) S.title = clip(e.text, 80)
207    return next(e)
208  })
209
210  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
211    if (!settings.on || !dir) return next(e)
212    const raw = String(e.file_path || e.notebook_path || '')
213    const key = normPath(raw, S.cwd)
214    if (!key || settings.ignore.some((s) => key.includes(s.toLowerCase()))) return next(e)
215    now = await $.clock.now()
216    const hit = collisionFor(key, await readOthers($))
217    if (hit) {
218      await readRecording($)
219      const refusal = await decide($, raw, key, hit)
220      if (refusal) return refusal
221      now = await $.clock.now()
222    }
223    // recorded before the edit runs, so a chat editing at the same moment sees it
224    S.files[key] = { path: raw, at: now }
225    await writeOwn($)
226    return next(e)
227  })
228
229  on('command.run', { command: ['guard', 'collision-guard'] }, async ($, e) => {
230    const [key, value] = String(e.args || '').trim().split(/\s+/)
231    const k = (key || '').toLowerCase()
232    if (k === 'on' || k === 'off') {
233      settings.on = k === 'on'
234      await $.store.set('settings', settings)
235      return { text: `Collision Guard ${settings.on ? 'on' : 'off'} in every chat that starts from now (and this one).` }
236    }
237    if (k === 'window' && Number(value) > 0) {
238      settings.windowMin = Math.round(Number(value))
239      await $.store.set('settings', settings)
240      return { text: `Collision Guard now counts another chat's edits from the last ${settings.windowMin} minutes.` }
241    }
242    return { text: await statusText($) }
243  })
244}
245
hooks/coach.mjs 325 lines
1// Shared source. Copied into each mod's hooks/ folder by _dev/sync-shared.mjs.
2// The Session Coach (Command Center's Coach tab) and the Collision Guard: pure
3// functions over what each open chat reports in its heartbeat. No mods API calls.
4
5import { clip, basename, tokens, minutes, usd } from './fmt.mjs'
6import { rewriteCost } from './pricing.mjs'
7
8const MIN = 60000
9
10// One spelling per file, so two chats' edits compare: forward slashes, no . or
11// .., and lowercase on Windows, where C:\X and c:/x are the same file
12export function normPath(p, cwd) {
13  let s = String(p || '').trim().replace(/\\/g, '/')
14  if (!s) return ''
15  if (/^\/[a-zA-Z]\//.test(s)) s = s[1] + ':' + s.slice(2) // Git Bash /c/Users is C:/Users
16  const isAbs = /^[A-Za-z]:\//.test(s) || s.startsWith('/')
17  if (!isAbs && cwd) s = String(cwd).replace(/\\/g, '/').replace(/\/+$/, '') + '/' + s
18  const parts = []
19  for (const part of s.split('/')) {
20    if (part === '.' || (part === '' && parts.length)) continue
21    if (part === '..') {
22      if (parts.length > 1) parts.pop()
23      continue
24    }
25    parts.push(part)
26  }
27  s = parts.join('/')
28  return /^[A-Za-z]:/.test(s) ? s.toLowerCase() : s
29}
30
31// A path shown relative to a root when it sits inside it
32export function relTo(path, root) {
33  const p = String(path || '').replace(/\\/g, '/')
34  const r = String(root || '').replace(/\\/g, '/').replace(/\/+$/, '')
35  if (r && p.toLowerCase().startsWith(r.toLowerCase() + '/')) return p.slice(r.length + 1)
36  return p
37}
38
39// Where Claude Code keeps a chat's transcript: the folder is the working
40// directory with every character but letters and digits turned into "-"
41export function transcriptPath(home, cwd, id) {
42  if (!home || !cwd || !id) return ''
43  return `${home}/.claude/projects/${String(cwd).replace(/[^A-Za-z0-9]/g, '-')}/${id}.jsonl`
44}
45
46// `git rev-parse --abbrev-ref HEAD --show-toplevel --git-dir --git-common-dir`
47// prints four lines; a worktree's git dir differs from the common one
48export function gitInfo(stdout, cwd) {
49  const lines = String(stdout || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
50  if (lines.length < 4) return null
51  const [branch, root, gitDir, common] = lines
52  return { branch, root, worktree: normPath(gitDir, cwd) !== normPath(common, cwd) }
53}
54
55// A chat's name for people: its first prompt, without the "<folder> · " prefix
56export function chatName(hb) {
57  const name = String(hb?.title || String(hb?.label || '').replace(/^[^·]*·\s*/, '')).trim()
58  return name || basename(hb?.cwd) || 'a chat'
59}
60
61function lastActive(c) {
62  return c.activeAt || c.lastDone?.at || 0
63}
64
65// ---------- rules: facts the Coach sees without AI ----------
66
67// Two open chats changed the same file recently, or a big chat sits idle long
68// enough that its prompt cache has expired
69export function ruleTips(chats, now, opts = {}) {
70  const overlapMs = (opts.overlapMin ?? 60) * MIN
71  const parkIdleMs = (opts.parkIdleMin ?? 60) * MIN
72  const parkTokens = opts.parkTokens ?? 250000
73  const live = (chats || []).filter((c) => c && c.id)
74  const tips = []
75  for (let i = 0; i < live.length; i++) {
76    for (let j = i + 1; j < live.length; j++) {
77      const a = live[i]
78      const b = live[j]
79      const recentA = new Map((a.edits || []).filter((e) => now - e.at <= overlapMs).map((e) => [e.key, e]))
80      const shared = (b.edits || []).filter((e) => now - e.at <= overlapMs && recentA.has(e.key))
81      if (!shared.length) continue
82      const lastA = Math.max(...shared.map((e) => recentA.get(e.key).at))
83      const lastB = Math.max(...shared.map((e) => e.at))
84      // the chat that touched the shared files last is the one that walked in
85      const later = lastB >= lastA ? b : a
86      const earlier = later === a ? b : a
87      // fold into the chat that's further along
88      const into = (earlier.turns || 0) >= (later.turns || 0) ? earlier : later
89      const from = into === a ? b : a
90      const untracked = shared.filter((e) => e.tracked === false || recentA.get(e.key).tracked === false).map((e) => e.path)
91      tips.push({
92        id: 'overlap:' + [a.id, b.id].sort().join('+'),
93        kind: 'overlap',
94        source: 'rule',
95        at: Math.max(lastA, lastB),
96        chats: [earlier.id, later.id],
97        files: shared.map((e) => e.path),
98        untracked,
99        actions: [...(untracked.length ? [] : [{ kind: 'worktree', target: later.id }]), { kind: 'merge', from: from.id, into: into.id }],
100      })
101    }
102  }
103  for (const c of live) {
104    if (c.state === 'working' || c.state === 'waiting') continue
105    const since = lastActive(c)
106    if (!since || !c.ctx || c.ctx < parkTokens || now - since < parkIdleMs) continue
107    tips.push({
108      id: 'park:' + c.id,
109      kind: 'park',
110      source: 'rule',
111      at: since,
112      chats: [c.id],
113      ctx: c.ctx,
114      idleMs: now - since,
115      cost: rewriteCost(c.ctx, c.model),
116      actions: [{ kind: 'handoff', target: c.id }],
117    })
118  }
119  return tips
120}
121
122// ---------- the AI pass ----------
123
124export const AI_KINDS = ['merge', 'split', 'worktree', 'park', 'focus']
125
126export const COACH_SYSTEM = `You coach one person who runs many Claude Code chats at once. You get a card for each open chat. Suggest how to organize them. Reply with JSON only, no prose and no code fence, in this shape:
127{"tips":[{"kind":"merge","chats":["c1","c2"],"into":"c2","target":"c1","title":"...","why":"..."}],"groups":[{"name":"...","chats":["c1","c2"]}]}
128
129Kinds:
130- merge: two or three chats whose prompts describe the same output (the same file, video, page, or document), or one redoing the other's work. "into" is the chat to keep (further along or more context). Chats on different parts of one project are not a merge: put them in the same group instead. A thin card (no prompts or files) is not evidence for a merge.
131- split: one chat's recent prompts started a task unrelated to its first prompt, and its context is large (over 150k tokens). "target" is that chat. Name the new task in the title.
132- worktree: two chats change files in the same project folder at the same time and could overwrite each other. "target" is the chat that should move. Never for chats that only read.
133- park: idle 60 minutes or more with over 200k tokens of context. "target" is that chat.
134- focus: too many chats working at once on unrelated things, or several waiting on the person. At most one.
135
136Rules:
137- 0 to 5 tips, most useful first. No tip beats a weak one. Never invent chats; use only the card ids.
138- The person never sees the card ids, so title and why name chats by what they do, never as c1 or c2.
139- title: under 70 characters, plain words.
140- why: one or two sentences under 200 characters that cite evidence from the cards (prompts, file names, minutes, tokens).
141- groups: put every chat in exactly one group of related work, 2 to 6 groups, names under 24 characters.
142- Write plainly. No em dashes, no hype.`
143
144// One card per chat, with short ids (c1, c2, ...) so the reply can't invent sessions
145export function coachCards(chats, now) {
146  const ids = new Map()
147  const blocks = (chats || []).map((c, i) => {
148    const sid = 'c' + (i + 1)
149    ids.set(sid, c.id)
150    const lines = [`${sid}: ${clip(chatName(c), 90)}`]
151    let state
152    if (c.state === 'working') state = `working (${c.activity || 'thinking'})`
153    else if (c.state === 'waiting') state = `waiting on the person (${c.waitingFor || 'a question'})`
154    else state = lastActive(c) ? `idle ${minutes(now - lastActive(c))}` : 'idle, no prompt yet'
155    lines.push(`  state: ${state}`)
156    const folder = String(c.cwd || '').replace(/\\/g, '/')
157    lines.push(`  folder: ${folder}${c.repo ? ` (git branch ${c.repo.branch}${c.repo.worktree ? ', in a worktree' : ''})` : ''}`)
158    lines.push(c.ctx ? `  context: ${tokens(c.ctx)} tokens, ${c.turns || 0} turns` : `  turns: ${c.turns || 0}`)
159    const prompts = (c.prompts || []).map((p) => `"${clip(p.text, 140)}"`)
160    if (prompts.length) lines.push(`  recent prompts, oldest first: ${prompts.join(' | ')}`)
161    if (c.lastDone?.text) lines.push(`  last answer began: "${clip(c.lastDone.text, 140)}"`)
162    const files = (c.edits || []).slice(-8).map((e) => relTo(e.path, c.repo?.root || c.cwd))
163    if (files.length) lines.push(`  files it changed: ${files.join(', ')}`)
164    return lines.join('\n')
165  })
166  const names = new Map((chats || []).map((c) => [c.id, chatName(c)]))
167  return { text: `${blocks.length} open chats:\n\n${blocks.join('\n\n')}`, ids, names }
168}
169
170// Change detection for the automatic pass: who's open, what they were asked,
171// what they touched, and context in 100k steps
172export function coachBasis(chats) {
173  return (chats || [])
174    .map((c) => [c.id, chatName(c), (c.prompts || []).at(-1)?.at || 0, (c.edits || []).length, Math.round((c.ctx || 0) / 100000)].join('|'))
175    .sort()
176    .join('\n')
177}
178
179function plain(text, max) {
180  return clip(String(text || '').replace(/\s*[—–]\s*/g, ', '), max)
181}
182
183// The model's JSON, checked: known kinds, real chats only, short text. Card ids
184// it slipped into the words ("c3") become the chat's name, since the person
185// never sees the cards. `names` maps a session id to its name.
186export function parseCoachReply(text, ids, names = new Map()) {
187  const words = (s, max) => plain(String(s || '').replace(/\b(c\d+)\b/g, (m) => (ids.has(m) && names.has(ids.get(m)) ? `"${clip(names.get(ids.get(m)), 40)}"` : m)), max)
188  const s = String(text || '')
189  const start = s.indexOf('{')
190  const end = s.lastIndexOf('}')
191  if (start < 0 || end <= start) return null
192  let v
193  try {
194    v = JSON.parse(s.slice(start, end + 1))
195  } catch {
196    return null
197  }
198  if (!v || typeof v !== 'object') return null
199  const real = (x) => ids.get(String(x || '').trim())
200  const tips = []
201  for (const t of Array.isArray(v.tips) ? v.tips : []) {
202    const kind = String(t?.kind || '').toLowerCase()
203    if (!AI_KINDS.includes(kind)) continue
204    const chats = [...new Set((Array.isArray(t.chats) ? t.chats : []).map(real).filter(Boolean))]
205    // nobody folds four chats into one: a merge that wide is a group, not a tip
206    const named = Array.isArray(t.chats) ? t.chats.length : 0
207    if (!chats.length || (kind === 'merge' && (chats.length < 2 || named > 3))) continue
208    const title = words(t.title, 90)
209    if (!title) continue
210    const tip = { id: `ai:${kind}:${[...chats].sort().join('+')}`, kind, source: 'ai', chats, title, why: words(t.why, 260) }
211    if (kind === 'merge') tip.into = chats.includes(real(t.into)) ? real(t.into) : chats[0]
212    if (kind === 'worktree' || kind === 'split' || kind === 'park') tip.target = chats.includes(real(t.target)) ? real(t.target) : chats[chats.length - 1]
213    tips.push(tip)
214    if (tips.length >= 5) break
215  }
216  const groups = []
217  const placed = new Set()
218  for (const g of Array.isArray(v.groups) ? v.groups : []) {
219    const chats = (Array.isArray(g?.chats) ? g.chats : []).map(real).filter((id) => id && !placed.has(id))
220    const name = plain(g?.name, 24)
221    if (!name || !chats.length) continue
222    chats.forEach((id) => placed.add(id))
223    groups.push({ name, chats })
224    if (groups.length >= 8) break
225  }
226  return { tips, groups }
227}
228
229// What each tip's buttons do
230export function tipActions(tip) {
231  if (tip.actions) return tip.actions
232  if (tip.kind === 'merge') return tip.chats.filter((id) => id !== tip.into).slice(0, 2).map((from) => ({ kind: 'merge', from, into: tip.into }))
233  if (tip.kind === 'worktree') return [{ kind: 'worktree', target: tip.target }]
234  if (tip.kind === 'split') return [{ kind: 'split', target: tip.target, topic: tip.title }]
235  if (tip.kind === 'park') return [{ kind: 'handoff', target: tip.target }]
236  return []
237}
238
239// Rule tips are facts and come first. An AI tip about chats that closed, or one
240// a rule already covers (same chats), is dropped.
241export function mergeTips(rule, ai, liveIds, dismissed) {
242  const live = new Set(liveIds)
243  const done = dismissed || {}
244  const covered = new Set(rule.map((t) => [...t.chats].sort().join('+')))
245  const out = [...rule]
246  for (const t of ai || []) {
247    if (!t.chats.every((id) => live.has(id))) continue
248    if (covered.has([...t.chats].sort().join('+')) && t.kind !== 'split' && t.kind !== 'focus') continue
249    out.push(t)
250  }
251  return out.filter((t) => !done[t.id])
252}
253
254// ---------- words on screen ----------
255
256const TAGS = { overlap: 'SAME FILES', park: 'PARK', merge: 'MERGE', split: 'SPLIT', worktree: 'WORKTREE', focus: 'FOCUS' }
257
258export function describeTip(tip, byId, now) {
259  const name = (id, n = 40) => `"${clip(chatName(byId.get(id) || { title: 'a closed chat' }), n)}"`
260  const tag = TAGS[tip.kind] || tip.kind.toUpperCase()
261  if (tip.kind === 'overlap') {
262    const files = tip.files.map(basename)
263    const list = files.length > 2 ? `${files.slice(0, 2).join(', ')} and ${files.length - 2} more` : files.join(' and ')
264    const why = tip.untracked.length
265      ? `Two chats changing the same files overwrite each other. ${basename(tip.untracked[0])} isn't tracked in git, so a worktree wouldn't carry it: fold one chat into the other instead.`
266      : 'Two chats changing the same files overwrite each other. Give the newer chat its own worktree, or fold one chat into the other.'
267    return { tag, title: `${name(tip.chats[0], 32)} and ${name(tip.chats[1], 32)} both edited ${list}`, why, when: tip.at }
268  }
269  if (tip.kind === 'park' && tip.source === 'rule') {
270    return {
271      tag,
272      title: `${name(tip.chats[0])} holds ${tokens(tip.ctx)} of context and has sat idle ${minutes(tip.idleMs)}`,
273      why: `Its prompt cache has expired, so the next message there rewrites it (about ${usd(tip.cost)}). If you're done with it, close it. If not, hand it off now so the next step starts small.`,
274      when: 0,
275    }
276  }
277  return { tag, title: tip.title, why: tip.why, when: 0 }
278}
279
280export function actionLabel(action, byId) {
281  const name = (id) => `"${clip(chatName(byId.get(id) || { title: 'a closed chat' }), 24)}"`
282  if (action.kind === 'worktree') return `worktree ${name(action.target)}`
283  if (action.kind === 'merge') return `merge into ${name(action.into)}`
284  if (action.kind === 'handoff') return `hand off ${name(action.target)}`
285  if (action.kind === 'split') return `split ${name(action.target)}`
286  return action.kind
287}
288
289// The prompt a button puts in a chat's box. `relay` is what another chat sends
290// on when the target runs older mod code and can't take it directly.
291export function actionPrompt(action, tip, byId, transcript) {
292  const name = (id) => `"${clip(chatName(byId.get(id) || { title: 'another chat' }), 80)}"`
293  if (action.kind === 'worktree') {
294    const other = tip.chats.find((id) => id !== action.target)
295    const files = (tip.files || []).map((f) => relTo(f, byId.get(action.target)?.cwd))
296    const text = `Another open Claude Code chat (${name(other)}) is changing the same files as this one${files.length ? `: ${files.join(', ')}` : ''}. Move the rest of this chat's work into a git worktree so the two stop overwriting each other. First list the files you already changed here (they stay in the main checkout), then use the EnterWorktree tool and continue there. Tell me the worktree's path and branch.`
297    return { target: action.target, text, relay: text }
298  }
299  if (action.kind === 'merge') {
300    const where = transcript
301      ? ` Its transcript is ${transcript}. It's JSONL, so read the tail, not the whole file: its user prompts and your final answers.`
302      : ' Ask me to paste its handoff.'
303    const text = `Fold another Claude Code chat into this one. It works on the same thing: ${name(action.from)}.${where} Sum up in five bullets what it did and what's left, then carry on with that work here without redoing what's finished. When you're done, tell me it's safe to close the other chat.`
304    return { target: action.into, text, relay: text }
305  }
306  if (action.kind === 'handoff') {
307    return { target: action.target, text: '/handoff', relay: 'Run the session-handoff skill now so this chat\'s work is saved before I close it.' }
308  }
309  if (action.kind === 'split') {
310    const text = `This chat has picked up a separate task: ${action.topic}. Write a short handoff for only that part (the goal, what's done, the key files, and the next step) so I can start it in a fresh chat. Don't keep working on it here.`
311    return { target: action.target, text, relay: text }
312  }
313  return null
314}
315
316// For a chat on older mod code: ask Claude here to find it and pass the prompt on
317export function relayPrompt(targetHb, relay) {
318  return `Find my other Claude Code chat whose first prompt was "${clip(chatName(targetHb), 120)}" (search_session_transcripts or list_sessions will find it). Send it this message with SendMessage, word for word, then tell me it arrived:\n\n${relay}`
319}
320
321export function organizePrompt(groups, byId) {
322  const lines = groups.map((g) => `${g.name}:\n${g.chats.map((id) => `- "${clip(chatName(byId.get(id) || {}), 100)}"`).join('\n')}`)
323  return `Organize my Claude Code chats in the Desktop sidebar into these groups with your sidebar tools (create_group, move_sessions). Find each chat with list_sessions or search_session_transcripts by its first prompt. Show me the plan first and wait for my yes. Don't archive or delete anything.\n\n${lines.join('\n\n')}`
324}
325
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
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