Collision Guard hooks module

Managed with chezmoi. Shell, terminals, editors, git, and the Claude Code setup I actually work in.
brew install chezmoi
chezmoi init --apply rcliao
That prompts for three things and writes them to ~/.config/chezmoi/chezmoi.toml, which stays local to the machine and is never committed:
| Prompt | Why it is per-machine |
|---|---|
| Full name | git author name |
| Git email | so a work laptop and a personal box can share this repo without cross-signing commits |
| Ghost hooks | see Ghost below |
Re-running chezmoi init later is non-interactive — it keeps whatever is already set. To change an answer, edit ~/.config/chezmoi/chezmoi.toml and chezmoi apply. For an unattended install, chezmoi init --apply --promptDefaults takes the personal identity with Ghost off.
The first apply runs brew bundle for everything below. A package failure warns rather than aborting, so the dotfiles land either way.
chezmoi diff # what would change
chezmoi apply # everything
chezmoi apply ~/.zshrc # just one target
chezmoi add ~/.tmux.conf # pull a local edit back into the repo
chezmoi update # git pull + apply
This repo is edited from more than one machine, so read chezmoi diff before applying. Whichever machine committed last is not automatically the machine that is more correct.
Shell — .zshrc (zsh, pure prompt, shared history across panes, cached compinit), .gitconfig (delta pager, zdiff3 conflicts, rerere, auto-set upstream on push), .config/git/ignore, .ssh/config (keychain-backed agent loading — this is what replaces the ssh-add -A that used to run on every shell start).
Terminals — ghostty.
Multiplexers — herdr (primary, keys mapped to match tmux muscle memory), tmux as the fallback. The old zellij config is kept under archive/, which is not deployed.
Editors — neovim (0.12+: one init.lua, plugins via the built-in vim.pack, LSP for Go, TypeScript, Rust, Python and Terraform from Brewfile-installed servers), emacs.
Runtimes — mise, activated at the end of .zshrc so it wins over the PATH exports above it. Homebrew still provides the global node, go, and python; mise only takes over inside a directory that pins a version in mise.toml or .tool-versions.
Window management — aerospace.
Claude Code — CLAUDE.md, settings.json, and the hook scripts under .claude/hooks. settings.json is a template: hooks belonging to tools that may not be installed (herdr, Zero) are only wired in when their script is actually present, so a machine never ends up pointing at a hook that does not exist.
Other — bat, yazi.
The ghost-* hooks drive the Ghost MCP memory server. A machine without Ghost has no use for them, so they are off by default: neither the scripts nor their settings.json entries are installed.
To turn them on, set ghost = true in ~/.config/chezmoi/chezmoi.toml and chezmoi apply. To turn them off again, flip it back and apply.
Homebrew refuses casks from untrusted taps, and trusting one is a decision worth making deliberately, so the bundle does not install aerospace:
brew trust --cask nikitabobko/tap/aerospace # this one cask, not the whole tap
brew install --cask nikitabobko/tap/aerospace
Anything regenerated, tool-managed, or machine-specific — Claude Code session transcripts and caches, plugin directories, herdr's own integration hook, and ~/.config/chezmoi/chezmoi.toml itself. See .chezmoiignore, which lists each exclusion explicitly so chezmoi add ~/.claude can never sweep one in.
This repo is public, so nothing employer-specific goes in it.
hooks/register.mjs 245 lines1// 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}
245hooks/coach.mjs 325 lines1// 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}
325hooks/fmt.mjs 70 lines1// 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}
70hooks/privacy.mjs 337 lines1// 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}
337hooks/pricing.mjs 96 lines1// 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