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

Current publication inventory — 2026-10-24: 135 bilingual posts across10 series (7 Tutorials /96 articles,3 Thoughts /39 essays),147 HTML files (146 enumerated), 466 images,135 feed items and146 sitemap URLs. AI-Core Functional Workflows adds twelve lessons paired with Anirach/ai-core-functional-tutorial,108 offline tests and twelve exercises. Earlier dated measurements below retain their historical context.
The personal site of Dr. Anirach Mingkhwan — Associate Professor at King Mongkut's University of Technology North Bangkok (KMUTNB), PhD (Liverpool John Moores University, UK), researcher in Agentic AI, computer networks, and big data analytics.
🌐 Live: anirach.com
A hand-written static site — seven top-level pages, four per-book detail pages, and 135 self-contained blog posts catalogued on two pages: Tutorials (/blog/, seven technical series) and Thoughts (/thoughts/, thirty-nine essays on living well and working well). No build system, no package manager, no dependencies, no JavaScript framework. Push to main and GitHub Pages publishes it.
| Pages | Home · Tutorials (/blog/) · Thoughts (/thoughts/) · Publications · Books · Projects & Apps · News & Updates |
| Posts | 135, in 10 series across two catalogs, every one bilingual Thai/English |
| Files | 147 HTML files on disk (146 enumerated by the linter; 404.html is deliberately excluded) |
| Stack | Plain HTML5 + CSS3, zero JavaScript. Google Fonts is the only external dependency |
| Hosting | GitHub Pages (classic Jekyll build) on the custom domain anirach.com, fronted by Cloudflare |
| Build step | None |
| Tests | Four scripts, run before every push — check_site.py alone carries 62 cross-file integrity checks. This is the test suite |
| Page | What it holds |
|---|---|
index.html | Single-scroll consultant profile: hero, at-a-glance stats, six services, engagement approach, selected work, about and career, research, free resources, latest news with the featured book, contact |
blog/ | The Tutorials catalog — 96 cards in seven technical series, plus all 135 post files |
thoughts/ | The Thoughts catalog — 39 essay cards in three series, each linking ../blog/<slug>.html |
publications/ | The academic record: the Springer book Libraries in Transformation, 8 book chapters, and a selected-publications table |
books/ | Books & writing: four works — One Day of Light (the free last-lecture event book, EN/TH editions with free PDF downloads served from books/), the published novel Three Old Men: The Last Conversation, and two complete bilingual manuscripts — each with its own detail page |
projects/ | Live apps (each verified working before it ships) and research-code repositories |
news/ | Reverse-chronological timeline of publications, talks and appointments, plus a career timeline |
The posts never move. Every post is blog/<slug>.html, including the 39 essays that /thoughts/ catalogues — the 2026-09-08 split separated the two catalogs, not the files.
books/ was one "Books & Writing" page until 2026-08-23, when it split: academic content moved to the new publications/, and books/ became the fiction section with one detail page per book. On 2026-08-24 it gained a fourth work, the Last Lecture companion book One Day of Light, whose EN/TH PDFs are downloadable for free from books/.
script.js is gone — deleted 2026-08-26. The site loads no executable JavaScript at all, and check_site.py INV-38 fails the build on any <script> that is not application/ld+json (schema.org metadata, which the browser parses as data and never executes — 128 pages carry one). Each of the script's four jobs has a CSS replacement in style.css: the scroll-reveal and the nav scroll state are scroll-driven CSS animations (animation-timeline: view() / scroll(root)), the landing page's mobile menu is .nav__links:target, and anchor scrolling is native scroll-behavior: smooth. The other pages' mobile menu is a pure-CSS checkbox toggle — a second mechanism, also without JavaScript.
Both are static, zero-JavaScript and generated by scripts/reindex_blog.py — re-run it rather than hand-ordering cards. Each is a hero, a sticky chip strip, a "Start here" feature, a row of series tiles, then one numbered-row-card section per series. There is no filtering, sorting or search UI.
Tutorials — /blog/, hero reads 7 Series · 96 Articles
| Series | Posts | Navigation |
|---|---|---|
| ⧉ AI-Core Functional Workflows | 12 | generated12-chip strip · GitHub code, tests and exercises |
| 🛡️ Engineering AI-Core Systems | 10 | chip strip · generated |
| 💻 Hermes Desktop Hands-On | 7 | chip strip · generated |
| 🧭 AI Transformation for Organizations | 20 | grouped chip strip (Reframe · Redesign · Engineer · Lead) · generated |
| ⚡ Hermes Agent in Practice | 10 | chip strip |
| 🤖 OpenClaw for Organizations | 13 | seven are a numbered chip-strip series; six stand alone with no nav |
| 🔧 DevOps & Vibe Coding | 24 | a single prev/next chain |
Thoughts — /thoughts/, hero reads 3 Series · 39 Essays
| Series | Essays | Navigation |
|---|---|---|
| 🌱 Wisdom for a Good Life | 20 | grouped chip strip, four arcs of five (Self · Mind · Work · Others) · generated |
| 🧭 Working Philosophies | 10 | chip strip, one working week Monday to Friday · generated |
| 🌅 Life Thought & Philosophy | 9 | chip strip, one day in three parts (Morning · Noon · Twilight) |
Five of the nine series are generated from a manifest plus per-post content sheets: python3 scripts/build_series.py --series <name>, with manifests in scripts/series/ (ai-core, ai-transformation, good-life, hermes-desktop, working). For those, the manifest and the sheets are the edit surface — never the emitted post.
Every post is bilingual. All 123 carry a pure-CSS Thai ⇄ English switch: a ไทย · English pill in the hero swaps between two complete tracks of the same article. Thai is the default, there is no JavaScript, and the page-level <html lang="th"> never changes — the English track is lang="en" inside it. The seven top-level pages and the four book detail pages are lang="en" chrome and wrap their Thai passages in <span lang="th">.
The Thoughts essays are written in a different register from the tutorials — literary Thai in the author's own voice, not code-switched technical prose. The brief, with ten rules and worked before/after pairs, is .claude/skills/blog-post/references/thai-essay-voice.md.
.
├── index.html # Landing page — the ONLY file that loads style.css
├── style.css # Landing-page styles; carries the canonical :root token block
├── _config.yml # Jekyll: keeps internal working docs out of the published site
├── CNAME # anirach.com
├── sitemap.xml # 134 URLs, generated · feed.xml — 123 items, generated · llms.txt · robots.txt
├── blog/ # index.html (the Tutorials catalog, 96 cards) + all 123 self-contained posts
├── thoughts/ # index.html (the Thoughts catalog — 39 essay cards linking ../blog/)
├── books/ # index.html + 4 per-book detail pages + 3 free PDFs, all self-contained
├── publications/ projects/ news/ # one self-contained index.html each
├── images/ # 431 files — 124 covers (800×800) + 8 book faces, 123 card thumbs (144×144),
│ # 130 share cards (1200×630), diagrams, series figures, posters, profile photo
├── scripts/ # 13 generators and sweeps (build_series, reindex_blog, gen_feed, gen_sitemap,
│ # make_cover, make_figure, bilingualize, check_visibility, …)
├── docs/ # design spec, implementation plan, OpenClaw runbook (not published)
└── .claude/skills/ # the four maintenance skills (not published)
The single most important fact before editing:
index.html ──▶ style.css (the ONLY consumer of it)
blog/index.html ──▶ its own <style> block, no JS
blog/<post>.html ──▶ its own <style> block, no JS × 123
thoughts/ books/ (index + 4 detail) publications/ projects/ news/ 404.html ──▶ same × 10
Every page embeds its complete stylesheet. There is no shared partial, template engine or token file, so a "global" change means editing N files by hand — and nothing tells you when file 23 of 135 got missed. That is what the linter is for. The compensating upside: blast radius is exactly one file.
No install, no build. Serve the repository root:
python3 -m http.server 8000 # → http://localhost:8000
Serve the root, not
blog/. Every blog page references images as../images/….
Known local-dev trap. The eight chip-strip series — 93 posts in all — link to each other with root-absolute, extensionless URLs (
/blog/openclaw-101). GitHub Pages resolves those;http.serverdoes not and returns 404. That is not a regression.
Run all four from the repo root before pushing. All must pass:
python3 .claude/skills/site-check/scripts/check_site.py # 62 cross-file integrity checks
python3 scripts/check_visibility.py --strict # search/AI-visibility, JSON-LD, metadata
python3 docs/openclaw/check-news-sync.py # news↔homepage sync, provenance, 4 counters
python3 .claude/skills/blog-post/assets/verify-wiring.py # blog post wiring
check_site.py runs 62 checks — 43 fail-severity, 17 warn, 2 info — and exits 0 when no fail-severity violation appears. Its baseline of pre-existing debt is empty since 2026-08-26 — the tree is clean (0 new, 0 known), so any violation it prints is new and any fail-severity one blocks the push. INV-25 fails the build if a baseline entry is ever added and goes stale, so the safety net cannot silently rot. INV-26 (added with the books split) ties every section-directory detail page to its own index.html: an orphan detail page, or an index card linking a file that does not exist, fails the build.
check-news-sync.py verifies the homepage strip mirrors the three newest news items, that every news item carries a <!-- source: … --> provenance comment, and that all four hand-typed counters match reality: news/index.html "7 updates", publications/index.html "8 chapters", and books/index.html "1 novel" and "2 complete" (it strips HTML comments first, so a commented-out card cannot satisfy a counter).
feed.xml and sitemap.xml are generated, not hand-written — scripts/gen_feed.py and scripts/gen_sitemap.py. INV-31 and INV-32 fail the build when either drifts from the catalogs. Run the sitemap in the commit after a content change, so the git lastmod dates exist.
git push origin main
That is the whole pipeline; GitHub Pages rebuilds in 1–3 minutes. _config.yml excludes docs/ and CLAUDE.md from the published output — they stay in the repository but are not served. Anything beginning with . or _ (including .claude/) is omitted by Jekyll automatically.
Cloudflare caches HTML for 10 minutes and assets for 4 hours. After a deploy, append
?cb=$RANDOMto check the real state before diagnosing a problem that is not there — and remember your own browser holds its copy for the same 10 minutes, so a stale tab needs a hard reload rather than a bug report.
Two measured costs were paid down on 2026-09-10, and both are easy to reintroduce:
Inter:wght@300..900 gets one variable file; the semicolon form Inter:wght@300;400;…;900 makes the API serve one static file per weight — 7 files and 331 KB for the latin subset against 1 file and 47 KB. Switching saved 189 KB per page, 56% of the font bytes. Sarabun keeps the semicolon form**: it has no variable version, a range request returns HTTP 400, and all five of its static weights are used.srcset. The row card renders its cover in a 72px box, so an 800×800 source was ~11× oversampled. /blog/ went from 3.63 MB to 0.42 MB, /thoughts/ from 2.11 MB to 0.38 MB.Both are documented with their measurements in the a11y-perf skill (R8b and R1b).
A post is not one file — it is a file plus a cover plus a share card plus a card plus counters plus its neighbours' nav links plus a feed item, a sitemap entry and an llms.txt line, in two language tracks, with no build step to catch a miss. The full recipe, including which of the four navigation patterns applies, is in .claude/skills/blog-post/SKILL.md. Start from .claude/skills/page-design/assets/post-template.html — the repo's single canonical template. For one of the five generated series, add a manifest row and run build_series.py instead; never hand-edit those posts.
Four skills in .claude/skills/ carry the verified detail:
| Skill | Covers |
|---|---|
| page-design | The house visual system — canonical tokens, type scale, component vocabulary, approved hero gradients, and the anti-patterns this repo has been burned by |
| blog-post | Adding, editing or removing a post without breaking the hand-wired navigation; plus the Thai essay voice brief |
| a11y-perf | Accessibility and performance rules with this site's real measured numbers |
| site-check | The linter, what every check means, and how to repair each failure |
Every number in those skills is load-bearing: a change that invalidates one must update it in the same commit. A confidently wrong measurement is worse than none, because it gets acted on.
News updates have their own runbook at docs/openclaw/latest-updates-runbook.md, written for an autonomous agent. Its gate enforces that every news item carries a verified source — no structural check can tell a true item from an invented one, so instead an item cannot exist without a stated source.
Open to research collaborations, speaking engagements, and academic partnerships.
No LICENSE file is present. Content and code are © Anirach Mingkhwan; all rights reserved by default.
Twelve bilingual lessons and the matching code are maintained together: Anirach/ai-core-functional-tutorial. The offline lab ZIP is downloads/ai-core-functional-lab.zip. The series manifest is scripts/series/ai-core-functional.json; article source links pin the reviewed companion commit. Default fixtures require no SDK install, API key or paid calls. Run the companion tests/advertised commands and the four website checks when revising. No additional reuse license is granted by the companion's public availability.
hooks/register.tsx 231 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 ACCENT = '#d97757'
17const BAND_TEXT = ' Screen-share mode: emails, phones, amounts and names hidden on screen'
18
19// Module variables reset on a reload; the mode itself lives in $.state and $.store.
20let sid = ''
21let home = ''
22// The bar glyph rows draw with now: light for the first frame after turning
23// on, then the full bar. A fade in of one step, 150 ms.
24let glyph = BAR
25const FADE_MS = 150
26
27// The /mods switchboard turns this mod off in every chat with switches.json.
28const NAME = 'screen-share'
29let off = false
30
31async function readSwitch($: EngineInterface) {
32 let now = false
33 try {
34 const path = `${$.plugin.root.split('/.claude/')[0] ?? ''}/.claude/mods/switches.json`
35 if (await $.fs.exists(path)) {
36 const json = JSON.parse(String(await $.fs.read(path))) as Record<string, unknown>
37 now = json[NAME] === false
38 }
39 } catch {
40 now = off
41 }
42 if (now === off) return
43 off = now
44 try {
45 $.ui.invalidate('ui.render')
46 } catch {
47 // The next state change redraws anyway.
48 }
49 try {
50 // The status line follows the switch: hidden while off, back when on again.
51 const { value } = await $.state.get(ON)
52 await $.ui.status(!off && value === true ? 'Screen-share on' : undefined)
53 } catch {
54 // The status line is a nicety.
55 }
56}
57
58async function logError($: EngineInterface, where: string, err: unknown) {
59 try {
60 const path = `${home || '~'}/.claude/mods-logs/screen-share.log`
61 let old = ''
62 try {
63 old = String(await $.fs.read(path))
64 } catch {
65 // First error: no log yet.
66 }
67 const at = new Date(await $.clock.now()).toISOString()
68 const msg = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
69 await $.fs.write(path, `${old}${at} ${where}: ${msg}\n`)
70 } catch {
71 // Logging must never break the screen.
72 }
73}
74
75async function isOn($: EngineInterface) {
76 if (off) return false
77 const { value } = await $.state.get(ON)
78 return value === true
79}
80
81// Every row this mod hooks is drawn again now, old messages included, instead
82// of at the next state redraw.
83function redrawAll($: EngineInterface) {
84 try {
85 $.ui.invalidate('ui.render')
86 } catch {
87 // The state write below redraws anyway, a little later.
88 }
89}
90
91async function setMode($: EngineInterface, on: boolean) {
92 glyph = on ? FADE : BAR
93 await $.state.set(ON, on)
94 redrawAll($)
95 if (on) {
96 $.clock.after(FADE_MS, () => {
97 glyph = BAR
98 redrawAll($)
99 })
100 }
101 try {
102 await $.ui.status(on ? 'Screen-share on' : undefined)
103 } catch (err) {
104 await logError($, 'ui.status', err)
105 }
106 try {
107 if (sid) await $.store.set(`on:${sid}`, on)
108 } catch (err) {
109 await logError($, 'store.set', err)
110 }
111}
112
113export const register: Register = on => {
114 on('session.start', async ($, e, next) => {
115 await $.command.register({
116 name: 'screen-share',
117 description: 'Screen-Share Mode on or off: hides emails, phone numbers, amounts and names on screen (Claude still reads the real data)',
118 argumentHint: '[on|off]',
119 // Works at once, also while Claude is busy, instead of after the turn.
120 immediate: true,
121 })
122 try {
123 sid = await $.session.id()
124 home = $.plugin.root.split('/.claude/')[0] ?? ''
125 await readSwitch($).catch(() => undefined)
126 $.clock.every(3000, () => {
127 void readSwitch($).catch(() => undefined)
128 })
129 // A reload keeps the mode this chat had.
130 const saved = await $.store.get(`on:${sid}`)
131 if (saved === true && (await $.state.get(ON)).value !== true) {
132 await $.state.set(ON, true)
133 if (!off) await $.ui.status('Screen-share on')
134 }
135 } catch (err) {
136 await logError($, 'session.start', err)
137 }
138 return next(e)
139 })
140
141 on('command.run', { command: 'screen-share' }, async ($, e) => {
142 if (off) return { text: 'Screen-Share Mode is off. Turn it on with /mods.' }
143 try {
144 const arg = String((e as { args?: string }).args ?? '').trim().toLowerCase()
145 const now = await isOn($)
146 const want = arg === 'on' ? true : arg === 'off' ? false : !now
147 await setMode($, want)
148 return {
149 text: want
150 ? '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.'
151 : 'Screen-share off. Everything shows as normal.',
152 }
153 } catch (err) {
154 await logError($, 'command.run', err)
155 return { text: 'Screen-share could not switch. See ~/.claude/mods-logs/screen-share.log.' }
156 }
157 })
158
159 on('ui.press', { plugin: 'screen-share', element: 'off' }, async ($, e) => {
160 try {
161 await setMode($, false)
162 } catch (err) {
163 await logError($, 'ui.press', err)
164 }
165 return { element: e.element }
166 })
167
168 // The transcript rows. Each hook reads the mode while drawing, so a switch
169 // redraws every row that read it. Nothing changed: the props pass as they came.
170
171 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
172 if (!(await isOn($))) return next(e)
173 const text = mask(e.props.text, glyph)
174 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
175 })
176
177 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
178 if (!(await isOn($))) return next(e)
179 const text = mask(e.props.text, glyph)
180 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
181 })
182
183 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
184 if (!(await isOn($))) return next(e)
185 const text = mask(e.props.text, glyph)
186 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
187 })
188
189 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
190 if (!(await isOn($))) return next(e)
191 const input = maskDeep(e.props.input, glyph)
192 const output = maskDeep(e.props.output, glyph)
193 if (input === e.props.input && output === e.props.output) return next(e)
194 return next({ ...e, props: { ...e.props, input, output } })
195 })
196
197 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
198 if (!(await isOn($))) return next(e)
199 const output = maskDeep(e.props.output, glyph)
200 return output === e.props.output ? next(e) : next({ ...e, props: { ...e.props, output } })
201 })
202
203 // The folded group line only counts calls ("Read 3 files"); its unfolded rows
204 // are ToolUse rows, masked above. The calls are masked too, for any surface
205 // that draws a detail from them.
206 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
207 if (!(await isOn($))) return next(e)
208 const calls = maskDeep(e.props.calls, glyph)
209 return calls === e.props.calls ? next(e) : next({ ...e, props: { ...e.props, calls } })
210 })
211
212 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
213 if (e.props.hasSurvey || !(await isOn($))) return next(e)
214 const rest = await next(e)
215 const { Box, Text, Button } = $.ui.resolve(e)
216 const noop = () => undefined
217 return (
218 <Box flexDirection="column">
219 {rest}
220 <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
221 <Text wrap="truncate-end">
222 <Text color={ACCENT}>●</Text>
223 <Text dimColor>{BAND_TEXT}</Text>
224 </Text>
225 <Button key="off" label="Turn off" onPress={noop} />
226 </Box>
227 </Box>
228 )
229 })
230}
231hooks/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