SLOPSHOPPER

recording-mode

Screen-record Claude Code without leaking secrets: tells the model to keep keys, emails and private words off screen, and masks them in the transcript display…

newbandrowscommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · recording-mode
› fix the failing auth test and add an audit log call ╭───────────────────╮ │ recording-mode │ ⏺ Read(src/auth.ts) │ Recording mode ON │ ⎿ 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 › /rec ⎿ recording-mode: Recording mode is on. REC ● ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ recording-mode: ● REC

Draws

Band
REC ● ⟨Claude Code's own drawing⟩
README

lchase

Lawrence Chase's Claude Code marketplace: code review, diagram, and test-evidence skills, plus a set of small Claude Code mods that add band lines and panes to the terminal UI.

/plugin marketplace add lchase/skills
/plugin install smart-review@lchase
/plugin install tldraw@lchase
/plugin install test-drive@lchase
/plugin install cache-clock@lchase
/plugin install usage-gauge@lchase
/plugin install auto-handoff@lchase
/plugin install agent-watch@lchase
/plugin install recording-mode@lchase

Each plugin is independent; install only what you want. @lchase is the marketplace name (from .claude-plugin/marketplace.json), not the GitHub slug.

Updating

New versions ship whenever the version string in a plugin's manifest changes. Claude Code refreshes marketplaces in the background by default, so you normally pick up updates without doing anything. To pull them right now:

/plugin marketplace update lchase      # refresh this marketplace's manifest
/plugin update smart-review@lchase     # apply a pending update to one plugin
/plugin update tldraw@lchase

/plugin marketplace update (no name) refreshes every marketplace. From a shell: claude plugin marketplace update lchase and claude plugin update <plugin>@lchase.

Reinstalling

If a plugin gets into a bad state (stale cached script, half-applied update), do a clean reinstall:

/plugin uninstall tldraw
/plugin marketplace update lchase
/plugin install tldraw@lchase

Nuclear option, re-register the whole marketplace:

/plugin marketplace remove lchase
/plugin marketplace add lchase/skills
/plugin install smart-review@lchase
/plugin install tldraw@lchase

Migrating from the old layout

Before this repo became a multi-plugin marketplace it published one plugin from the repo root under a marketplace named chase. If you installed it back then, clear the old registration first:

/plugin uninstall smart-review
/plugin uninstall tldraw            # only if you installed the short-lived tldraw@chase
/plugin marketplace remove chase
/plugin marketplace add lchase/skills
/plugin install smart-review@lchase
/plugin install tldraw@lchase

The tldraw skill's command also changed: /tldraw:tldraw → /tldraw:diagram.

PluginWhat it doesSkills / commandsHarnesses
smart-reviewEnsemble code review: six specialized lenses over a diff, merged into one deduplicated, severity-ranked verdict/smart-review:{review,min,max,add-pr-review,review-pr-comments} + auto-triggerClaude Code, Cursor, Codex, Gemini CLI, any AGENTS.md agent
tldrawNatural-language description → editable tldraw document (.tldr) + rendered PNG/SVG/tldraw:diagram + auto-triggerClaude Code only (needs Node + npx)
test-driveBefore/after evidence of a UI or API change: record the app before you edit, replay the same scripted scenario after, with a deterministic verdict/test-drive:{baseline,compare} + auto-triggerClaude Code only (needs Node; UI also needs Playwright and ffmpeg)
cache-clockMod: band line showing whether the prompt cache is warm, with countdown, reuse %, cached tokens, and estimated cold-rewrite costCompact button, /cache-clockClaude Code only
usage-gaugeMod: band line with plan usage windows (5-hour, weekly, spend limit), reset times, pace warning, session cost on API billing, threshold toasts/usage-gauge (detail pane)Claude Code only
auto-handoffMod: band line showing how full the chat is; saves a HANDOFF.md at a threshold (auto or on request), then clear and continue or resume in a new session/auto-handoffClaude Code only
agent-watchMod: band line showing who is doing the work this turn (main chat, helper agents, models, tasks, status, skills), plus a pane with per-agent blocks, per-model totals, timeline/agent-watchClaude Code only
recording-modeMod: screen-record without leaking secrets; tells the model to keep keys, emails, private words off screen and masks them in the transcript display only, red REC chip/recClaude Code only

smart-review

An ensemble code review: several specialized lenses, one merged verdict, not another pile of comments.

Most code review, human or AI, is one reviewer making a single pass and leaving a list of comments. This runs six specialized lenses instead (spec-conformance, correctness, security, performance, design, and tests), then merges the results into one verdict: duplicates collapsed, findings that several lenses independently raise weighted up, conflicting advice reconciled, severity rolled up, structural problems first, nits capped. The merge is the point. An ensemble without it is just louder, and a longer checklist handed to one reviewer only dilutes its attention and repeats its own misses.

Two modes: a fast single pass (min) for tight loops, and an orchestrated ensemble (max) for pre-merge review. Auto-routing picks between them by change size and sensitivity.

The skill lives in plugins/smart-review/skills/smart-review/ as harness-neutral markdown. Each harness gets a thin manifest pointing at that same directory, so no skill content is copied or forked.

HarnessInstallminmaxSlash commands / auto-trigger
Claude Code/plugin marketplace add lchase/skills then /plugin install smart-review@lchase✅✅ isolated parallel subagents✅
Cursorpoint Cursor at plugins/smart-review/.cursor-plugin/✅✅ sequential ensemblevia session-start hook
Codexplugins/smart-review/.codex-plugin/ manifest✅✅ sequential ensembleno
Gemini CLIgemini extensions install https://github.com/lchase/skills (reads root gemini-extension.json)✅✅ sequential ensemblevia GEMINI.md
Any AGENTS.md agentreads root AGENTS.md✅✅ sequential ensembleno

max's isolated parallel subagents are a Claude Code capability. A model panel (plugins/smart-review/skills/smart-review/references/ensemble.md, off by default) rides on top when the harness can route more than one model. Otherwise max is single-model. Elsewhere max runs the same spec-gate, six lenses, and merge as a disciplined sequential walk. Design and internals: plugins/smart-review/SMART-REVIEW.md.

Claude Code slash commands:

  • /smart-review:review: auto (routes to min or max by size + sensitivity)
  • /smart-review:min: fast single-pass review
  • /smart-review:max: orchestrated ensemble review
  • /smart-review:add-pr-review <PR number or URL>: reviews a GitHub PR and, after you confirm what to publish, posts the findings as PR comments
  • /smart-review:review-pr-comments <PR number or URL>: triages a PR's existing unresolved review comments, fixes or explains each after your approval, replies, and resolves the thread

The skill also triggers automatically when you ask Claude to review a diff, PR, or branch.


tldraw

Turn a described diagram (flowchart, architecture / system diagram, process map, decision tree, state machine) into two artifacts: an editable .tldr document (opens at tldraw.com or in the tldraw editor) and a rendered PNG/SVG for docs and PRs.

Claude writes a {nodes, edges} spec from your description; a bundled Node script does a simple layered auto-layout, emits the .tldr, and renders images by loading it into a headless-Chromium tldraw instance via @kitschpatrol/tldraw-cli (first run downloads Chromium, ~1 min, cached).

/plugin install tldraw@lchase

Claude Code only. Skill: /tldraw:diagram (also auto-triggers on "draw this diagram", "diagram this flow", "make a tldraw of…"). Details: plugins/tldraw/skills/diagram/SKILL.md.


test-drive

A test drive is an e2e test that happens to produce video, and the "before" is part of the process rather than something reconstructed later. Run /test-drive:baseline before you edit: it writes a short browser scenario and records the app as it stands. Make the change, then run /test-drive:compare: it replays the identical scenario with Playwright and composes a labeled side-by-side MP4. The pass/warn/fail verdict comes from the scenario's assertions, not from reading the video.

Backend changes work the same way with "kind": "api": the scenario is a list of HTTP steps (method, path, body, expected status, JSON fields, and server-log lines to expect or avoid). There is nothing to screen-record, so each step captures the request, the response, and the server log lines it produced, and compare writes a before/after report.md instead of a video. Secrets are redacted from the stored transcript (best effort, extendable per scenario with redact), and the finished report is scanned and flagged if anything still looks secret.

/plugin install test-drive@lchase

Claude Code only. UI scenarios need ffmpeg and Playwright in the repo under test; API scenarios need only Node 18+. Only the working tree is ever run, so there is no second checkout to boot. Details: baseline and compare.


Claude Code mods

Five small plugins that change Claude Code's own interface instead of adding a task skill. Each draws a band line above the prompt (some add a side pane or slash command), is independent of the others, and works on Claude Code only.

  • cache-clock: is the prompt cache still warm? Countdown, reuse percentage, cached tokens, and the estimated cost of a cold rewrite, with a Compact button. Config: cacheMinutes (5 or 60, default 60) to match your cache TTL.
  • usage-gauge: plan usage windows (5-hour, weekly, spend limit) with reset times and a pace warning; session cost on API billing; threshold toasts. /usage-gauge opens a detail pane.
  • auto-handoff: watches context fill and saves a HANDOFF.md at a threshold (automatically or on request), then clears and continues, or resumes in a new session. /auto-handoff.
  • agent-watch: who is doing the work this turn: main chat, helper agents, models, tasks, status, skills. /agent-watch opens a pane with per-agent blocks, per-model totals, and a timeline.
  • recording-mode: for screen recordings. Tells the model to keep keys, emails, and private words off screen and masks them in the transcript display only (the underlying transcript is untouched). Red REC chip, /rec to toggle.
/plugin install cache-clock@lchase    # or usage-gauge, auto-handoff, agent-watch, recording-mode

Repo layout

.claude-plugin/marketplace.json   # the "lchase" marketplace: 8 plugin entries
AGENTS.md                         # repo-level agent pointer (→ smart-review)
gemini-extension.json + GEMINI.md # Gemini CLI installs the repo as an extension (→ smart-review)

plugins/smart-review/             # plugin 1, multi-harness code review
  .claude-plugin/plugin.json
  skills/smart-review/            # the harness-neutral core (SKILL.md + references/)
  agents/ commands/ hooks/        # Claude Code packaging
  .cursor-plugin/ .codex-plugin/  # thin per-harness manifests
  scripts/                        # validate-adapters.sh, bump-version.sh
  evals/                          # dev-only eval kit (not shipped to installers)
  SMART-REVIEW.md                 # design writeup

plugins/tldraw/                   # plugin 2, Claude Code only
  .claude-plugin/plugin.json
  skills/diagram/                 # SKILL.md + scripts/build-tldr.mjs + references/

plugins/test-drive/               # plugin 3, Claude Code only
  .claude-plugin/plugin.json
  skills/{baseline,compare}/      # SKILL.md each
  scripts/{lib.mjs,record.mjs,record-api.mjs,redact.mjs,verdict.mjs,compose.sh,report.mjs}   # shared by both skills
  references/                     # UI and API scenario schemas + examples

plugins/{cache-clock,usage-gauge,auto-handoff,agent-watch,recording-mode}/   # plugins 4-8, Claude Code mods
  .claude-plugin/plugin.json      # each with its own version
  hooks/                          # register.tsx (the mod) + hooks.json, tests where present
  types/                          # mod API typings

Developing

claude plugin validate .                                # marketplace + all plugins
./plugins/smart-review/scripts/validate-adapters.sh      # smart-review's per-harness manifests

Releasing: users only get an update when a plugin's version string changes, so bump it on every user-facing change and push to main.

  • smart-review: version is pinned across all its manifests; bump them together with ./plugins/smart-review/scripts/bump-version.sh <version>.
  • tldraw, test-drive, and each mod: version independently; edit that plugin's .claude-plugin/plugin.json.

Once pushed, installed clients pick it up on their next background marketplace refresh, or immediately with /plugin marketplace update lchase.

Source 3 files
hooks/register.tsx 187 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Settings } from '../types'
5import { HELP, hint, mask, maskDeep, policyText } from './lib'
6import type { MaskOptions } from './lib'
7
8const DEFAULTS: Settings = { isOn: false, isStrict: false }
9const settings = atom({ plugin: 'recording-mode', key: 'settings' } as const, DEFAULTS)
10
11const isSettings = (v: unknown): v is Settings =>
12  typeof v === 'object' &&
13  v !== null &&
14  typeof (v as Settings).isOn === 'boolean' &&
15  typeof (v as Settings).isStrict === 'boolean'
16
17const loadWords = async ($: EngineInterface): Promise<string[]> => {
18  const raw = await $.store.get('words')
19
20  return Array.isArray(raw) ? raw.filter((w): w is string => typeof w === 'string') : []
21}
22
23/** The masking options, or null while recording mode is off. */
24const optionsOf = async ($: EngineInterface): Promise<MaskOptions | null> => {
25  const cfg = ((await read($, settings)) as Settings | undefined) ?? DEFAULTS
26  if (!cfg.isOn) return null
27
28  return { isStrict: cfg.isStrict, words: await loadWords($) }
29}
30
31function show($: EngineInterface, cfg: Settings) {
32  $.ui.status(cfg.isOn ? (cfg.isStrict ? '● REC strict' : '● REC') : undefined)
33}
34
35async function apply($: EngineInterface, cfg: Settings) {
36  await update($, settings, () => cfg)
37  await $.store.set('settings', cfg)
38  show($, cfg)
39}
40
41export const register: Register = on => {
42  on('session.start', async ($, e, next) => {
43    const saved = await $.store.get('settings')
44    if (isSettings(saved)) {
45      await update($, settings, () => saved)
46      if (saved.isOn) {
47        show($, saved)
48        $.ui.toast('Recording mode is still ON')
49      }
50    }
51    await $.command.register({
52      name: 'rec',
53      description: 'Recording mode: mask secrets on screen',
54      argumentHint: 'strict | off | hide <word> | unhide <word> | list | test <text>',
55    })
56
57    return next(e)
58  })
59
60  on('prompt.compose', async ($, e, next) => {
61    const result = await next(e)
62    const cfg = ((await read($, settings)) as Settings | undefined) ?? DEFAULTS
63    if (!cfg.isOn) return result
64
65    return {
66      sections: [
67        ...result.sections,
68        { id: 'recording-mode:policy', text: policyText(cfg.isStrict), scope: 'session' as const },
69      ],
70    }
71  })
72
73  on('command.run', { command: 'rec' }, async ($, e) => {
74    const [head = '', ...rest] = e.args.trim().split(/\s+/)
75    const verb = head.toLowerCase()
76    const arg = rest.join(' ').trim()
77    const cfg = ((await read($, settings)) as Settings | undefined) ?? DEFAULTS
78
79    if (verb === '' || verb === 'on') {
80      const next: Settings = verb === '' && cfg.isOn ? { ...cfg, isOn: false } : { isOn: true, isStrict: false }
81      await apply($, next)
82      $.ui.toast(next.isOn ? 'Recording mode ON' : 'Recording mode OFF')
83
84      return { text: next.isOn ? 'Recording mode is on.' : 'Recording mode is off.' }
85    }
86    if (verb === 'strict') {
87      await apply($, { isOn: true, isStrict: true })
88      $.ui.toast('Recording mode ON (strict)')
89
90      return { text: 'Recording mode is on, strict: amounts and home paths hidden too.' }
91    }
92    if (verb === 'off') {
93      await apply($, { ...cfg, isOn: false })
94      $.ui.toast('Recording mode OFF')
95
96      return { text: 'Recording mode is off.' }
97    }
98    if (verb === 'hide' || verb === 'unhide') {
99      if (arg === '') return { text: `Usage: /rec ${verb} <word>` }
100      const words = await loadWords($)
101      const has = words.some(w => w.toLowerCase() === arg.toLowerCase())
102      if (verb === 'hide') {
103        if (!has) await $.store.set('words', [...words, arg])
104
105        return { text: `Hiding ${hint(arg)} (${has ? words.length : words.length + 1} private words).` }
106      }
107      await $.store.set('words', words.filter(w => w.toLowerCase() !== arg.toLowerCase()))
108
109      return { text: has ? `Unhid ${hint(arg)}.` : `${hint(arg)} was not hidden.` }
110    }
111    if (verb === 'list') {
112      const words = await loadWords($)
113
114      return {
115        text: words.length === 0 ? 'No private words.' : `${words.length} private words: ${words.map(hint).join(' ')}`,
116      }
117    }
118    if (verb === 'test') {
119      const words = await loadWords($)
120      // Preview shows what strict-or-not would look like for the current setting, masked even while off.
121      const out = mask(e.args.trim().slice(head.length).trim(), { isStrict: cfg.isStrict, words })
122
123      return { text: `Masked: ${out}` }
124    }
125
126    return { text: HELP }
127  })
128
129  // Display only: each site rewrites the props the engine draws, never what the model read.
130  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
131    const o = await optionsOf($)
132    if (o === null) return next(e)
133
134    return next({ ...e, props: { ...e.props, text: mask(e.props.text, o) } })
135  })
136
137  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
138    const o = await optionsOf($)
139    if (o === null) return next(e)
140
141    return next({ ...e, props: { ...e.props, text: mask(e.props.text, o) } })
142  })
143
144  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
145    const o = await optionsOf($)
146    if (o === null) return next(e)
147
148    return next({ ...e, props: { ...e.props, text: mask(e.props.text, o) } })
149  })
150
151  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
152    const o = await optionsOf($)
153    if (o === null) return next(e)
154
155    return next({ ...e, props: { ...e.props, input: maskDeep(e.props.input, o) } })
156  })
157
158  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
159    const o = await optionsOf($)
160    if (o === null) return next(e)
161
162    return next({ ...e, props: { ...e.props, output: maskDeep(e.props.output, o) } })
163  })
164
165  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
166    const cfg = ((await read($, settings)) as Settings | undefined) ?? DEFAULTS
167    if (e.props.hasSurvey || !cfg.isOn) return next(e)
168
169    const { Box, Text } = $.ui.resolve(e)
170    const chip = (
171      <Text bold color="#b8aece" backgroundColor="#3b2160">
172        {cfg.isStrict ? '  STRICT ● ' : '     REC ● '}
173      </Text>
174    )
175    const below = await next(e)
176
177    return below ? (
178      <Box flexDirection="column">
179        {chip}
180        {below}
181      </Box>
182    ) : (
183      chip
184    )
185  })
186}
187
hooks/lib.ts 77 lines
1export type MaskOptions = { isStrict: boolean; words: readonly string[] }
2
3export const OFF_SCREEN = "Recording mode is on, so I'll keep that off screen"
4
5export const POLICY = [
6  'Recording mode is on: the user is screen-recording this session.',
7  'Never print API keys, tokens, passwords, secret environment values, email addresses, phone numbers or the user\'s private words, whether they come from files, tool output or the user.',
8  'Refer to them generically ("your API key", "the email address in the config") and, when you hold back something the user asked for, say: "' + OFF_SCREEN + '".',
9  'Do not echo such values inside commands or code you show either; use placeholders.',
10].join('\n')
11
12const STRICT_POLICY = ' Also keep dollar amounts and home-folder paths off screen.'
13
14export const policyText = (isStrict: boolean): string => (isStrict ? POLICY + STRICT_POLICY : POLICY)
15
16const escape = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
17
18// Order matters: key shapes first so a token is never half-eaten by a later rule.
19const KEY_RULES: readonly (readonly [RegExp, string])[] = [
20  [/\bsk-[A-Za-z0-9_-]{16,}/g, '[key]'],
21  [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, '[key]'],
22  [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, '[key]'],
23  [/\bxox[abposr]-[A-Za-z0-9-]{10,}/g, '[key]'],
24  [/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/g, 'Bearer [key]'],
25  [
26    /^([ \t]*(?:export[ \t]+)?[A-Za-z_][A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL)[A-Za-z0-9_]*[ \t]*=[ \t]*)\S.*$/gim,
27    '$1[key]',
28  ],
29]
30
31const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g
32// Needs a separator or a leading plus, so commit ids and plain numbers stay.
33const PHONE = /(?<![\w.])(?:\+\d{1,3}[\s.-]?)?(?:\(\d{3}\)\s?|\d{3}[\s.-])\d{3}[\s.-]\d{4}(?![\w])|(?<![\w.])\+\d{1,3}[\s.-]\d{2,4}[\s.-]\d{3,4}(?:[\s.-]\d{2,4})?(?![\w])/g
34
35const MONEY = /[$€£]\s?\d[\d,]*(?:\.\d+)?(?:\s?[kKmMbB]\b)?/g
36const HOME = /(?:\/Users\/|\/home\/|[A-Za-z]:\\Users\\)[^\s/\\:"'`]+/g
37
38export function mask(text: string, o: MaskOptions): string {
39  let out = text
40  for (const [re, to] of KEY_RULES) out = out.replace(re, to)
41  out = out.replace(EMAIL, '[email]').replace(PHONE, '[phone]')
42  for (const w of o.words) {
43    if (w === '') continue
44    out = out.replace(new RegExp(`(?<![A-Za-z0-9])${escape(w)}(?![A-Za-z0-9])`, 'gi'), '[private]')
45  }
46  if (o.isStrict) out = out.replace(MONEY, '[amount]').replace(HOME, '[home]')
47
48  return out
49}
50
51/** Masks every string in a JSON-like value, leaving shape and other types alone. */
52export function maskDeep(v: unknown, o: MaskOptions): unknown {
53  if (typeof v === 'string') return mask(v, o)
54  if (Array.isArray(v)) return v.map(x => maskDeep(x, o))
55  if (typeof v === 'object' && v !== null) {
56    const out: Record<string, unknown> = {}
57    for (const [k, x] of Object.entries(v)) if (x !== undefined) out[k] = maskDeep(x, o)
58
59    return out
60  }
61
62  return v
63}
64
65/** First letter and dots, so a list never shows the word itself. */
66export const hint = (w: string): string => `${[...w][0] ?? ''}•••`
67
68export const HELP = [
69  '/rec            toggle recording mode',
70  '/rec strict     on, and also hide $ amounts and home-folder paths',
71  '/rec off        turn off',
72  '/rec hide <w>   mask a private word',
73  '/rec unhide <w> stop masking it',
74  '/rec list       count and first letters',
75  '/rec test <t>   preview masking',
76].join('\n')
77
types/index.d.ts 9 lines
1export type Settings = { isOn: boolean; isStrict: boolean }
2
3declare module 'claude-code' {
4  interface PluginState {
5    // Private words are never kept here, only in $.store.
6    'recording-mode': { settings: Settings }
7  }
8}
9