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

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.
| Mod | Commands | What it does |
|---|---|---|
| Cache Keeper | /cache, /keepwarm, /board, /handoff | Shows 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 config | Masks keys, personal details, and business figures on screen and keeps private files closed while you record |
| Goal Meter | /goals | A progress bar for /goal built from Claude's own task plan, with an ETA and every chat's goal in one pane |
| Collision Guard | /guard | Asks before Claude edits a file another open chat changed in the last 30 minutes |
claude --version. The mods work in the terminal and in the Desktop app's Code tab.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.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.
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.
● 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./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 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.
Run /rec before you hit record. It applies to every open chat within 3 seconds, and /rec off turns it off everywhere.
.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..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./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": []
}
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.
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.
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.◎ goal 69% · ~17m, visible even when the line above the prompt is hidden./goals: this chat's whole task list, plus every other chat's goal from the last 12 hours./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.
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:
/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.
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.
MIT. See LICENSE.
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