/screen-share draws emails, phone numbers, money amounts and names as shaded bars on screen for recording, while Claude still reads the real data

Mods for Claude Code.
| Mod | What it does |
|---|---|
| cache-bar | Status-line bar under your existing status line showing how long the prompt cache stays warm, with a handoff countdown: cache ▰▰▰▱▱▱▱▱ 23m 182k · handoff in 18m. Turns yellow at a quarter of the TTL, red at the handoff threshold, and follows the drop to a 5-minute TTL. /cache-bar:setup installs it. |
| filetree | Fork of data-goblin/claude-code-filetree that shows only the files Claude touched this session; the header's ◆ button toggles to the whole tree. |
| helper-desk | Task list above the prompt that follows Claude's to-do list, plus a side pane with one live card per subagent. |
| helpin-per | Pane listing open Helpin PER-* tasks; pick one to have Claude start working on it. |
| cache-clock | Countdown above the prompt until the chat's cache goes cold, with a Keep warm button. |
| handoff | When a chat gets very long, writes a handoff prompt and starts a fresh chat with it (/refresh <focus>; the handoff tells the new chat to run /pickup). |
| reflect | When you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or a rules folder. |
| chat-inventory | Side pane, In this chat, listing the to-do list, connectors and calls per connector, files and skills this chat used. |
| screen-share | /screen-share hides names, emails, phone numbers and money amounts on screen while you record. |
| replay-theater | /replay steps through the file edits Claude made in the last turn, one diff at a time. |
| mods | /mods opens a pane to turn each mod on or off, including mods installed from other marketplaces, and to install mods a marketplace offers that are not installed yet. |
| effort-optimizer | Before Claude starts on a prompt, a quick Sonnet check picks the effort level it needs (Low to Max) and offers a one-click switch, or sets it by itself with /effort-optimizer auto. |
| prompt-refiner | A Refine button in the hint line under the prompt, any time in the chat. Press it and pick a rewrite style for the prompt in the box, like a phone keyboard's suggestions: Clearer, Shorter, Elaborate (fuller, same shape), Structured (the job, the why, the guardrails, done means), Ask me first (up to 3 questions on gaps), or My guidance. The rewrite shows above the prompt with what changed; Use it fills the box, Refine runs another pass. /refine <prompt> does the same. |
The eight mods other than replay-theater, from helper-desk to effort-optimizer, come from Ben AI's benai-mods (benai.co) and are redistributed with permission, with Windows fixes added here. replay-theater (one Ben AI change: /mods switch support) is Anthropic's sample mod from anthropics/claude-code-playground, under the Apache 2.0 license in its LICENSE.
Reflect saves a rule either to ~/.claude/CLAUDE.md (Save everywhere) or as its own note in a folder you choose (Save to vault). Set that folder as rules_folder when you install reflect, or later for reflect in /config. If it is empty, reflect asks for the folder the first time you press Save to vault.
At the prompt of a terminal session:
/plugin install helper-desk --marketplace jz-wilson/claude-mods
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install helper-desk --marketplace jz-wilson/claude-mods
claude plugin install cache-bar --marketplace jz-wilson/claude-mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with jz-wilson/claude-mods, then Add plugin.
cache-bar is a status-line script, so it takes over statusLine and runs the previous status line inside it. After installing, run /cache-bar:setup. It:
settings.json to settings.json.bak-cache-bar;statusLine.command (claude-hud's, for example) into inner in ~/.claude/plugins/cache-bar/config.json;statusLine.command at the newest installed cache-bar version, and sets refreshInterval: 60 so the countdown repaints while you are idle.The previous status line prints first, unchanged, and the bar goes on its own line underneath. If the inner command fails, the bar still prints. Before the first request of a session there is no cache yet, so the bar is hidden; after expiry it shows a dim cold.
The same config file sets the handoff threshold and the context floor:
{ "inner": "…", "handoffMinutes": 5, "floorTokens": 80000 }
The threshold is capped at a fifth of the TTL, so it fits the 5-minute TTL too. Under floorTokens of context the bar says no handoff (<floor).
To undo, copy settings.json.bak-cache-bar back over settings.json, or set statusLine.command back to the inner value.
Each mod is a folder with .claude-plugin/plugin.json. CI runs claude plugin validate on every mod for each push and pull request, then claude plugin test for mods with a hooks module and bun test for the rest. A merge to main that bumps a mod's version publishes a GitHub release tagged <mod>-v<version>.
claude plugin validate agent-tree
claude plugin test agent-tree
bun test cache-barhooks/register.tsx 232 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { BAR, FADE, mask, maskDeep } from './mask'
4
5// Screen-Share Mode: /screen-share turns it on or off. (/share cannot be used: it
6// is the built-in /bug's other name, and the engine refuses to register it.) While on, the transcript rows
7// draw emails, phone numbers, money amounts and names as shaded bars of the
8// same length (▒▒▒▒ ▒▒▒), so lines keep their length and nothing jumps.
9// Only the drawing changes: the stored chat, and what Claude reads, stay real.
10//
11// Hover to reveal is not done: these rows are drawn by the engine from a
12// markdown string, and hover styles only apply to a Text or Box the mod draws
13// itself. Drawing our own rows would lose the engine's markdown.
14
15const ON = { plugin: 'screen-share', key: 'on' } as const
16const BAND_TEXT = ' Screen-share mode: emails, phones, amounts and names hidden on screen'
17
18// Module variables reset on a reload; the mode itself lives in $.state and $.store.
19let sid = ''
20let home = ''
21// The bar glyph rows draw with now: light for the first frame after turning
22// on, then the full bar. A fade in of one step, 150 ms.
23let glyph = BAR
24const FADE_MS = 150
25
26// The /mods switchboard turns this mod off in every chat with switches.json.
27const NAME = 'screen-share'
28let off = false
29
30async function readSwitch($: EngineInterface) {
31 let now = false
32 try {
33 const path = `${$.plugin.root.replace(/\\/g, '/').split('/.claude/')[0] ?? ''}/.claude/mods/switches.json`
34 if (await $.fs.exists(path)) {
35 const json = JSON.parse(String(await $.fs.read(path))) as Record<string, unknown>
36 now = json[NAME] === false
37 }
38 } catch {
39 now = off
40 }
41 if (now === off) return
42 off = now
43 try {
44 $.ui.invalidate('ui.render')
45 } catch {
46 // The next state change redraws anyway.
47 }
48 try {
49 // The status line follows the switch: hidden while off, back when on again.
50 const { value } = await $.state.get(ON)
51 await $.ui.status(!off && value === true ? 'Screen-share on' : undefined)
52 } catch {
53 // The status line is a nicety.
54 }
55}
56
57async function logError($: EngineInterface, where: string, err: unknown) {
58 try {
59 const path = `${home || '~'}/.claude/mods-logs/screen-share.log`
60 let old = ''
61 try {
62 old = String(await $.fs.read(path))
63 } catch {
64 // First error: no log yet.
65 }
66 const at = new Date(await $.clock.now()).toISOString()
67 const msg = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
68 await $.fs.write(path, `${old}${at} ${where}: ${msg}\n`)
69 } catch {
70 // Logging must never break the screen.
71 }
72}
73
74async function isOn($: EngineInterface) {
75 if (off) return false
76 const { value } = await $.state.get(ON)
77 return value === true
78}
79
80// Every row this mod hooks is drawn again now, old messages included, instead
81// of at the next state redraw.
82function redrawAll($: EngineInterface) {
83 try {
84 $.ui.invalidate('ui.render')
85 } catch {
86 // The state write below redraws anyway, a little later.
87 }
88}
89
90async function setMode($: EngineInterface, on: boolean) {
91 glyph = on ? FADE : BAR
92 await $.state.set(ON, on)
93 redrawAll($)
94 if (on) {
95 $.clock.after(FADE_MS, () => {
96 glyph = BAR
97 redrawAll($)
98 })
99 }
100 try {
101 await $.ui.status(on ? 'Screen-share on' : undefined)
102 } catch (err) {
103 await logError($, 'ui.status', err)
104 }
105 try {
106 if (sid) await $.store.set(`on:${sid}`, on)
107 } catch (err) {
108 await logError($, 'store.set', err)
109 }
110}
111
112export const register: Register = on => {
113 on('session.start', async ($, e, next) => {
114 await $.command.register({
115 name: 'screen-share',
116 description: 'Screen-Share Mode on or off: hides emails, phone numbers, amounts and names on screen (Claude still reads the real data)',
117 argumentHint: '[on|off]',
118 // Works at once, also while Claude is busy, instead of after the turn.
119 immediate: true,
120 })
121 try {
122 sid = await $.session.id()
123 home = $.plugin.root.replace(/\\/g, '/').split('/.claude/')[0] ?? ''
124 await readSwitch($).catch(() => undefined)
125 $.clock.every(3000, () => {
126 void readSwitch($).catch(() => undefined)
127 })
128 // A reload keeps the mode this chat had.
129 const saved = await $.store.get(`on:${sid}`)
130 if (saved === true && (await $.state.get(ON)).value !== true) {
131 await $.state.set(ON, true)
132 if (!off) await $.ui.status('Screen-share on')
133 }
134 } catch (err) {
135 await logError($, 'session.start', err)
136 }
137 return next(e)
138 })
139
140 on('command.run', { command: 'screen-share' }, async ($, e) => {
141 if (off) return { text: 'Screen-Share Mode is off. Turn it on with /mods.' }
142 try {
143 const arg = String((e as { args?: string }).args ?? '').trim().toLowerCase()
144 const now = await isOn($)
145 const want = arg === 'on' ? true : arg === 'off' ? false : !now
146 await setMode($, want)
147 return {
148 text: want
149 ? 'Screen-share on. Emails, phone numbers, amounts and names show as shaded bars on screen. Claude still reads the real data. Run /screen-share again to turn it off.'
150 : 'Screen-share off. Everything shows as normal.',
151 }
152 } catch (err) {
153 await logError($, 'command.run', err)
154 return { text: 'Screen-share could not switch. See ~/.claude/mods-logs/screen-share.log.' }
155 }
156 })
157
158 on('ui.press', { plugin: 'screen-share', element: 'off' }, async ($, e) => {
159 try {
160 await setMode($, false)
161 } catch (err) {
162 await logError($, 'ui.press', err)
163 }
164 return { element: e.element }
165 })
166
167 // The transcript rows. Each hook reads the mode while drawing, so a switch
168 // redraws every row that read it. Nothing changed: the props pass as they came.
169
170 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
171 if (!(await isOn($))) return next(e)
172 const text = mask(e.props.text, glyph)
173 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
174 })
175
176 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
177 if (!(await isOn($))) return next(e)
178 const text = mask(e.props.text, glyph)
179 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
180 })
181
182 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
183 if (!(await isOn($))) return next(e)
184 const text = mask(e.props.text, glyph)
185 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
186 })
187
188 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
189 if (!(await isOn($))) return next(e)
190 const input = maskDeep(e.props.input, glyph)
191 const output = maskDeep(e.props.output, glyph)
192 if (input === e.props.input && output === e.props.output) return next(e)
193 return next({ ...e, props: { ...e.props, input, output } })
194 })
195
196 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
197 if (!(await isOn($))) return next(e)
198 const output = maskDeep(e.props.output, glyph)
199 return output === e.props.output ? next(e) : next({ ...e, props: { ...e.props, output } })
200 })
201
202 // The folded group line only counts calls ("Read 3 files"); its unfolded rows
203 // are ToolUse rows, masked above. The calls are masked too, for any surface
204 // that draws a detail from them.
205 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
206 if (!(await isOn($))) return next(e)
207 const calls = maskDeep(e.props.calls, glyph)
208 return calls === e.props.calls ? next(e) : next({ ...e, props: { ...e.props, calls } })
209 })
210
211 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
212 if (e.props.hasSurvey || !(await isOn($))) return next(e)
213 const rest = await next(e)
214 const { Box, Text, Button } = $.ui.resolve(e)
215 const noop = () => undefined
216 return (
217 <Box flexDirection="column">
218 {rest}
219 <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
220 <Box flexShrink={1}>
221 <Text wrap="truncate-end">
222 <Text color="claude">●</Text>
223 <Text dimColor>{BAND_TEXT}</Text>
224 </Text>
225 </Box>
226 <Button key="off" label="Turn off" onPress={noop} />
227 </Box>
228 </Box>
229 )
230 })
231}
232hooks/mask.ts 400 lines1// The masking rules for Screen-Share Mode. Pure functions, no engine calls.
2// Conservative on purpose: a year, a version, a token count like 182k or a file
3// path must never be hidden. A miss is better than hiding plain numbers.
4//
5// Each hidden span is drawn as a shaded bar of the same length as the text it
6// hides (spaces stay spaces), so a line keeps its length and wrapping.
7
8export type Kind = 'email' | 'phone' | 'amount' | 'name'
9type Span = { a: number; b: number; kind: Kind }
10
11/** The bar a hidden span is drawn with, and the lighter one of the fade in. */
12export const BAR = '▒'
13export const FADE = '░'
14
15// Every regex is built once, here, when the module loads.
16
17// An ssh remote (git@github.com:me/repo) is not an email and stays.
18const EMAIL_SRC = String.raw`[A-Za-z0-9._%+-]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*\.[A-Za-z]{2,24}(?![A-Za-z0-9-])(?!:[\w~/])`
19const EMAIL_RE = new RegExp(EMAIL_SRC, 'g')
20
21// Links: phone and money hits inside one are left alone (ids, query strings).
22const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+/gi
23
24// Money. The number part: 4,500 / 4500 / 4,250.00 / 12.5
25const NUM = String.raw`\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?`
26const SCALE = String.raw`(?:\s?(?:k|K|m|M|bn|mn|b|B)\b|\s(?:thousand|million|billion|lakh|lakhs|crore|crores)\b)`
27const CODES = 'USD|GBP|EUR|INR|AUD|CAD|NZD|SGD|AED|JPY|CHF|HKD'
28const MONEY_RES: RegExp[] = [
29 // $1,234.56 £900 €12k ₹4,500 US$300 -$40 \$500 (escaped in markdown)
30 new RegExp(String.raw`(?<![\w$])(?:US|AU|CA|NZ|HK|S|A|C)?\\?[$£€₹¥]\s?(?:${NUM})${SCALE}?(?![\w%])`, 'g'),
31 // USD 300 INR 4,500 Rs. 900 Rs 1.2 lakh
32 new RegExp(String.raw`(?<![\w])(?:${CODES}|Rs\.?)\s?(?:${NUM})${SCALE}?(?![\w%])`, 'g'),
33 // 1,200 dollars 300 USD 12€ 5k euros
34 new RegExp(String.raw`(?<![\w.,])(?:${NUM})${SCALE}?\s?(?:${CODES}|€|dollars?|euros?|rupees?|bucks)(?![\w])`, 'g'),
35]
36const BARE_ARG_RE = /^\\?\$\d$/
37
38// Phone shapes. Each needs a boundary that is not a letter, digit, slash, dot,
39// hyphen, hash, equals sign or @ on either side, so ids and paths stay.
40const LEFT = String.raw`(?<![\w/\\.#=@+$£€₹-])`
41const RIGHT = String.raw`(?![\w/\\@%-]|\.\d|,\d)`
42const PHONE_RES: RegExp[] = [
43 // International: +44 7700 900123, +1 (415) 555-0132, +91 98765 43210, +919876543210
44 new RegExp(String.raw`${LEFT}\+\d{1,3}(?:[ .-]?\(?\d{1,5}\)?){1,6}${RIGHT}`, 'g'),
45 // US: (415) 555-0132, 415-555-0132, 415.555.0132, 415 555 0132, 1-415-555-0132
46 new RegExp(String.raw`${LEFT}(?:1[ .-])?(?:\(\d{3}\)\s?|\d{3}[ .-])\d{3}[ .-]\d{4}${RIGHT}`, 'g'),
47 // UK: 07700 900123, 020 7946 0958, (020) 7946 0958, 01632 960983
48 new RegExp(String.raw`${LEFT}(?:\(0\d{2,4}\)|0\d{2,4})[ -]?\d{3,4}[ -]?\d{3,4}${RIGHT}`, 'g'),
49 // India: 98765 43210, 98765-43210, 098765 43210, 9876543210
50 new RegExp(String.raw`${LEFT}0?[6-9]\d{4}[ -]?\d{5}${RIGHT}`, 'g'),
51]
52
53// A date or a time is never a phone, whatever its separators.
54const DATE_RES: RegExp[] = [
55 /^\(?(?:19|20)\d{2}[ .-](?:0?[1-9]|1[0-2])[ .-](?:0?[1-9]|[12]\d|3[01])\b/,
56 /^\(?(?:0?[1-9]|[12]\d|3[01])[ .-](?:0?[1-9]|1[0-2])[ .-](?:19|20)\d{2}\b/,
57 /^\(?(?:0?[1-9]|1[0-2])[ .-](?:0?[1-9]|[12]\d|3[01])[ .-](?:19|20)\d{2}\b/,
58]
59// Runs of plain years, "2024 2025 2026", read like a number but are not one.
60const YEARS_RE = /^(?:(?:19|20)\d{2}[ .-]?){2,}$/
61const NON_DIGIT_RE = /\D/g
62const PLUS_RE = /^\+/
63const LAST_GROUP_RE = /[ .-]?\(?\d+\)?$/
64const QUICK_RE = /[@\d:]/
65
66// Names. Only where the text says it is a person: "Name <email>", a name in
67// brackets or a table cell next to an email, a From:/To:/Cc: line, or a name
68// key in JSON. Never a guess at every capitalised word.
69const WORD = String.raw`\p{Lu}[\p{L}\p{M}'’.-]*`
70// Lowercase particles inside a name: Ben van Sprundel, Ana de la Cruz.
71const PARTICLE = String.raw`(?:van|von|de|der|den|da|di|du|la|le|del|dos|das|bin|ibn|al)`
72const WORDS = String.raw`${WORD}(?:[ \t]+(?:${PARTICLE}[ \t]+){0,2}${WORD}){0,3}`
73const NB = String.raw`(?<![\p{L}\p{N}_@.])`
74// Each email is looked at once, with a short window of text either side of
75// it, instead of running every name pattern over the whole message.
76const WINDOW = 80
77const NAME_WORD_RE = new RegExp(String.raw`^${WORD}$`, 'u')
78const PARTICLES = new Set(['van', 'von', 'de', 'der', 'den', 'da', 'di', 'du', 'la', 'le', 'del', 'dos', 'das', 'bin', 'ibn', 'al'])
79const BEFORE_SEPS = '<(,:|-–—'
80const LEAD_PUNCT = '*_"\'([|>'
81// "Jane Doe" <jane@
82const QUOTED_RE = /"([^"\n<>@]{1,60})"[ \t]*<[ \t]*$/
83// jane@x.com (Jane Doe) | jane@x.com | Jane Doe |
84const AFTER_RE = new RegExp(String.raw`^>?[ \t]*(?:\([ \t]*(${WORDS})[ \t]*\)|\|[ \t]*(${WORDS})[ \t]*\|)`, 'u')
85// Words that open a phrase but are not part of the name after them.
86const STOP = new Set([
87 'From', 'To', 'Cc', 'Bcc', 'Mail', 'Email', 'E-mail', 'Contact', 'Call', 'Hi', 'Hello', 'Hey', 'Dear',
88 'Thanks', 'Ask', 'Ping', 'Message', 'Write', 'Send', 'Reply', 'By', 'And', 'Or', 'The', 'Sender',
89 'Recipient', 'With', 'Via', 'Name', 'Owner', 'Lead', 'Customer', 'Client',
90])
91// A From:, To:, Cc: style line, markdown bold allowed around the label.
92const HEADER_RE = /^([ \t>*_-]*[*_]{0,2}(?:From|To|Cc|Bcc|Reply-To|Sender|Recipients?|Attendees?|Organi[sz]er|Invitees?|Guests?|Contact|Full[ _]name|Name)[*_]{0,2}[ \t]*:[*_]{0,2}[ \t]*)([^\n]*)$/gim
93const PART_SPLIT_RE = /,|;|[ \t]+and[ \t]+|[ \t]+&[ \t]+/g
94const PART_NAME_RE = new RegExp(String.raw`^[ \t]*[*_"']{0,2}(${WORDS})[*_"']{0,2}[ \t]*(?=$|<|\(|\[)`, 'u')
95// JSON: "full_name": "Jane Doe", and "name" inside an object that has an email.
96const NAME_KEY_JSON_RE = /"(?:full_?name|first_?name|last_?name|display_?name|given_?name|family_?name|sender_?name|from_?name|contact_?name|person_?name|author_?name|owner_?name)"\s*:\s*"([^"\\\n]{1,80})"/gi
97const FLAT_OBJECT_RE = /\{[^{}]{0,3000}\}/g
98const HAS_EMAIL_KEY_RE = /"e-?mail(?:_?address)?"\s*:/i
99const NAME_JSON_RE = /"name"\s*:\s*"([^"\\\n]{1,80})"/g
100const STARTS_LETTER_RE = /^[\p{L}]/u
101const SPACE_RE = /\s/
102const NOT_SPACE_RE = /\S/gu
103
104/** Keys whose string value is a person's name (any case, _ optional). */
105export const NAME_KEY_RE = /^(?:full_?name|first_?name|last_?name|display_?name|given_?name|family_?name|sender_?name|from_?name|contact_?name|person_?name|author_?name|owner_?name)$/i
106const EMAIL_KEY_RE = /^e-?mail(?:_?address)?$/i
107
108function digits(s: string) {
109 return s.replace(NON_DIGIT_RE, '').length
110}
111
112function isPhone(hit: string) {
113 const n = digits(hit)
114 if (n < 9 || n > 15) return false
115 const bare = hit.replace(PLUS_RE, '')
116 if (DATE_RES.some(r => r.test(bare))) return false
117 if (YEARS_RE.test(bare)) return false
118 return true
119}
120
121// How much of a hit is the phone: the whole, or less once trailing groups that
122// belong to the next number (a year after it) are cut off. 0 when none of it.
123function phoneLength(hit: string) {
124 let h = hit
125 while (h) {
126 if (isPhone(h)) return h.length
127 const cut = h.replace(LAST_GROUP_RE, '')
128 if (cut === h || digits(cut) < 9) return 0
129 h = cut
130 }
131 return 0
132}
133
134// Which characters are already taken, one byte each: an overlap test is then
135// a short scan of the hit, not a pass over every span found so far.
136function taken(used: Uint8Array, a: number, b: number) {
137 for (let i = a; i < b; i++) if (used[i]) return true
138 return false
139}
140
141function take(used: Uint8Array, a: number, b: number) {
142 used.fill(1, a, b)
143}
144
145// A name found at `at`: leading opener words (From, Hi, Contact) are dropped,
146// and it needs at least `min` words left.
147function addName(spans: Span[], name: string, at: number, min: number) {
148 const words = name.split(/([ \t]+)/)
149 let start = at
150 while (words.length && STOP.has(words[0] ?? '')) {
151 start += (words[0] ?? '').length + (words[1] ?? '').length
152 words.splice(0, 2)
153 }
154 const kept = words.filter((_, i) => i % 2 === 0)
155 if (kept.length < min || kept.length === 0) return
156 const text = words.join('').replace(/[ \t]+$/, '')
157 if (!text) return
158 spans.push({ a: start, b: start + text.length, kind: 'name' })
159}
160
161// Walks back from an email over a separator (< ( , : | -) and up to four
162// capitalised words. Plain string steps, no regex over the whole window.
163function isBlank(c: string | undefined) {
164 return c === ' ' || c === '\t'
165}
166
167function nameBefore(text: string, end: number, spans: Span[]) {
168 let i = end
169 while (isBlank(text[i - 1])) i--
170 let sep = text[i - 1] ?? ''
171 if (!sep || !BEFORE_SEPS.includes(sep)) return
172 i--
173 while (isBlank(text[i - 1])) i--
174 // "Jane Doe, <jane@x.com>": the bracket sits after the real separator.
175 const prev = text[i - 1] ?? ''
176 if (sep === '<' && prev && prev !== '<' && BEFORE_SEPS.includes(prev)) {
177 sep = prev
178 i--
179 while (isBlank(text[i - 1])) i--
180 }
181 const nameEnd = i
182 const floor = Math.max(0, end - WINDOW)
183 let start = -1
184 let words = 0
185 while (words < 4 && i > floor) {
186 // The word that ends at i.
187 let j = i
188 while (j > floor && !SPACE_RE.test(text[j - 1] ?? ' ')) j--
189 let w = j
190 while (w < i && LEAD_PUNCT.includes(text[w] ?? '')) w++
191 const word = text.slice(w, i)
192 if (NAME_WORD_RE.test(word)) {
193 start = w
194 words++
195 } else if (!(PARTICLES.has(word) && words > 0 && w === j)) break
196 // Punctuation before a word, a line start or a wide gap ends the name.
197 if (w !== j || j <= floor || !isBlank(text[j - 1])) break
198 i = j
199 while (isBlank(text[i - 1])) i--
200 if (j - i > 2) break
201 }
202 if (start < 0 || nameEnd <= start) return
203 addName(spans, text.slice(start, nameEnd), start, sep === '<' || sep === '(' ? 1 : 2)
204}
205
206function nameSpans(text: string, spans: Span[], emails: Span[]) {
207 for (const e of emails) {
208 let from = Math.max(0, e.a - WINDOW)
209 // Start the window on a word boundary so a cut word is not read as a name.
210 while (from > 0 && !SPACE_RE.test(text[from - 1] ?? ' ')) from++
211 const before = text.slice(from, e.a)
212 const q = QUOTED_RE.exec(before)
213 if (q && q[1]) addName(spans, q[1], from + (q.index ?? 0) + 1, 1)
214 else nameBefore(text, e.a, spans)
215 const after = text.slice(e.b, e.b + WINDOW)
216 const n = AFTER_RE.exec(after)
217 const name = n?.[1] ?? n?.[2]
218 if (n && name) addName(spans, name, e.b + (n.index ?? 0) + n[0].indexOf(name), n[1] ? 1 : 2)
219 }
220 if (text.includes(':')) {
221 HEADER_RE.lastIndex = 0
222 for (const m of text.matchAll(HEADER_RE)) {
223 const label = m[1] ?? ''
224 const rest = m[2] ?? ''
225 const at = (m.index ?? 0) + label.length
226 let last = 0
227 PART_SPLIT_RE.lastIndex = 0
228 const parts: Array<[string, number]> = []
229 for (const sep of rest.matchAll(PART_SPLIT_RE)) {
230 parts.push([rest.slice(last, sep.index), last])
231 last = (sep.index ?? 0) + sep[0].length
232 }
233 parts.push([rest.slice(last), last])
234 for (const [part, off] of parts) {
235 const pm = PART_NAME_RE.exec(part)
236 if (!pm || !pm[1]) continue
237 addName(spans, pm[1], at + off + part.indexOf(pm[1]), 1)
238 }
239 }
240 }
241 if (text.includes('"')) {
242 NAME_KEY_JSON_RE.lastIndex = 0
243 for (const m of text.matchAll(NAME_KEY_JSON_RE)) {
244 const v = m[1] ?? ''
245 if (STARTS_LETTER_RE.test(v)) spans.push({ a: (m.index ?? 0) + m[0].length - 1 - v.length, b: (m.index ?? 0) + m[0].length - 1, kind: 'name' })
246 }
247 if (text.includes('@')) {
248 FLAT_OBJECT_RE.lastIndex = 0
249 for (const o of text.matchAll(FLAT_OBJECT_RE)) {
250 if (!HAS_EMAIL_KEY_RE.test(o[0])) continue
251 NAME_JSON_RE.lastIndex = 0
252 for (const m of o[0].matchAll(NAME_JSON_RE)) {
253 const v = m[1] ?? ''
254 const end = (o.index ?? 0) + (m.index ?? 0) + m[0].length - 1
255 if (STARTS_LETTER_RE.test(v)) spans.push({ a: end - v.length, b: end, kind: 'name' })
256 }
257 }
258 }
259 }
260}
261
262/** Every private span in `text`, sorted and merged. */
263export function findSpans(text: string): Span[] {
264 const spans: Span[] = []
265 if (!text || typeof text !== 'string') return spans
266 const hasAt = text.includes('@')
267 if (hasAt) {
268 EMAIL_RE.lastIndex = 0
269 for (const m of text.matchAll(EMAIL_RE)) spans.push({ a: m.index ?? 0, b: (m.index ?? 0) + m[0].length, kind: 'email' })
270 }
271 const emails = spans.slice()
272 if (/\d/.test(text)) {
273 const used = new Uint8Array(text.length)
274 for (const e of emails) take(used, e.a, e.b)
275 const inLink = new Uint8Array(text.length)
276 URL_RE.lastIndex = 0
277 for (const m of text.matchAll(URL_RE)) take(inLink, m.index ?? 0, (m.index ?? 0) + m[0].length)
278 for (const re of MONEY_RES) {
279 re.lastIndex = 0
280 for (const m of text.matchAll(re)) {
281 const a = m.index ?? 0
282 const b = a + m[0].length
283 if (BARE_ARG_RE.test(m[0]) || taken(inLink, a, b) || taken(used, a, b)) continue
284 spans.push({ a, b, kind: 'amount' })
285 take(used, a, b)
286 }
287 }
288 for (const re of PHONE_RES) {
289 re.lastIndex = 0
290 for (const m of text.matchAll(re)) {
291 const a = m.index ?? 0
292 const n = phoneLength(m[0])
293 if (n === 0 || taken(inLink, a, a + m[0].length) || taken(used, a, a + n)) continue
294 spans.push({ a, b: a + n, kind: 'phone' })
295 take(used, a, a + n)
296 }
297 }
298 }
299 nameSpans(text, spans, emails)
300 if (spans.length < 2) return spans
301 spans.sort((x, y) => x.a - y.a || y.b - x.b)
302 const out: Span[] = []
303 for (const s of spans) {
304 const prev = out[out.length - 1]
305 if (prev && s.a <= prev.b) {
306 if (s.b > prev.b) prev.b = s.b
307 } else out.push({ ...s })
308 }
309 return out
310}
311
312/** A bar as long as `hit`: every character but a space becomes `glyph`. */
313export function bar(hit: string, glyph = BAR) {
314 return hit.replace(NOT_SPACE_RE, glyph)
315}
316
317/**
318 * Rewrites every private span of `text` with `draw(hit, kind)`. Returns the
319 * same string when nothing is private.
320 */
321export function redact(text: string, draw: (hit: string, kind: Kind) => string): string {
322 if (!text || typeof text !== 'string') return text
323 // Quick exit: nothing that could be private.
324 if (!QUICK_RE.test(text)) return text
325 const spans = findSpans(text)
326 if (spans.length === 0) return text
327 let out = ''
328 let at = 0
329 for (const s of spans) {
330 out += text.slice(at, s.a) + draw(text.slice(s.a, s.b), s.kind)
331 at = s.b
332 }
333 return out + text.slice(at)
334}
335
336// Rows draw again on every scroll and redraw, so a long text is masked once
337// per glyph and kept.
338const cache = new Map<string, string>()
339const CACHE_MAX = 400
340
341/** Hides emails, phone numbers, money amounts and names in one string. */
342export function mask(text: string, glyph = BAR): string {
343 if (!text || typeof text !== 'string') return text
344 const long = text.length >= 200
345 const key = long ? glyph + text : ''
346 if (long) {
347 const hit = cache.get(key)
348 if (hit !== undefined) return hit
349 }
350 const out = redact(text, hit => bar(hit, glyph))
351 if (long) {
352 if (cache.size >= CACHE_MAX) {
353 const first = cache.keys().next().value
354 if (first !== undefined) cache.delete(first)
355 }
356 cache.set(key, out)
357 }
358 return out
359}
360
361/**
362 * Masks every string inside plain data (a tool's input or result), keeping its
363 * shape and keys, so a result still fits its tool's schema. Returns the same
364 * value when nothing changed.
365 */
366export function maskDeep<T>(value: T, glyph = BAR, depth = 0): T {
367 if (typeof value === 'string') return mask(value, glyph) as T
368 if (value === null || typeof value !== 'object' || depth > 30) return value
369 if (Array.isArray(value)) {
370 let changed = false
371 const out = value.map(v => {
372 const m = maskDeep(v, glyph, depth + 1)
373 if (m !== v) changed = true
374 return m
375 })
376 return (changed ? out : value) as T
377 }
378 const proto = Object.getPrototypeOf(value)
379 if (proto !== Object.prototype && proto !== null) return value
380 const entries = Object.entries(value as Record<string, unknown>)
381 const hasEmail = entries.some(([k]) => EMAIL_KEY_RE.test(k))
382 let changed = false
383 const out: Record<string, unknown> = {}
384 for (const [k, v] of entries) {
385 let m: unknown
386 // Ids are never private data and must reach the engine as they were.
387 if (k === 'tool_use_id' || k === 'id') m = v
388 else if (typeof v === 'string' && v && (NAME_KEY_RE.test(k) || (hasEmail && k === 'name'))) m = bar(v, glyph)
389 else m = maskDeep(v, glyph, depth + 1)
390 if (m !== v) changed = true
391 out[k] = m
392 }
393 return (changed ? out : value) as T
394}
395
396// Run every rule once at load, so the first row drawn after /screen-share does
397// not pay for building the patterns.
398mask('From: Jane Doe <jane@x.co> (Bob Lee) | Ann Wu | +44 7700 900123, $4,250.00, "full_name": "A", {"name": "B", "email": "c"}')
399cache.clear()
400types/index.d.ts 11 lines1export type ScreenShareMode = 'on' | 'off'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'screen-share': {
6 /** True while Screen-Share Mode hides private data on screen. */
7 on: boolean
8 }
9 }
10}
11