SLOPSHOPPER

screen-share

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

newbandrowscommandstatustimer
v0.2.1no licenseupdated 2026-10-23jz-wilson/claude-mods/screen-share
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · screen-share
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /screen-share ⎿ screen-share: Screen-share on. Emails, phone numbers, amounts and names show as shaded bars on screen. Claude still reads th ⟨Claude Code's own drawing⟩ ● Screen-share mode: emails, phones, amounts and names hidden on screen [ Turn off ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ screen-share: Screen-share on

Draws

Band
⟨Claude Code's own drawing⟩ ● Screen-share mode: emails, phones, amounts and names hidden on screen [ Turn off ]
README

claude-mods

Mods for Claude Code.

ModWhat it does
cache-barStatus-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.
filetreeFork 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-deskTask list above the prompt that follows Claude's to-do list, plus a side pane with one live card per subagent.
helpin-perPane listing open Helpin PER-* tasks; pick one to have Claude start working on it.
cache-clockCountdown above the prompt until the chat's cache goes cold, with a Keep warm button.
handoffWhen 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).
reflectWhen you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or a rules folder.
chat-inventorySide 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-optimizerBefore 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-refinerA 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 setting

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.

Install

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 and claude-hud

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:

  • backs up settings.json to settings.json.bak-cache-bar;
  • moves the current statusLine.command (claude-hud's, for example) into inner in ~/.claude/plugins/cache-bar/config.json;
  • points 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.

Development

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-bar
Source 3 files
hooks/register.tsx 232 lines
1import 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}
232
hooks/mask.ts 400 lines
1// 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()
400
types/index.d.ts 11 lines
1export 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