Live pane of the browser Claude drives: the latest screenshot from Claude in Chrome, the page URL and the recent actions

English | 日本語
Personal Claude Code mods (function-hook plugins), published as one marketplace.
| Mod | What it does |
|---|---|
agent-pane | Live pane of the session's subagents: running ones with elapsed time, finished ones with result and duration |
done-gate | Flags unverified edits, stops repeated failing commands, and blocks git --no-verify |
sandbox-lens | Tallies sandbox denials seen in Bash output and reports them with /sandbox-report |
skill-hint | Suggests up to three matching skills above the prompt from what was typed; never invokes them. |
todo-agent | Collects #TODO(agent) follow-ups from Claude's replies into a per-project list with a /todos pane. |
context-meter | Band above the prompt with a Gruvbox sparkline of context use per turn, plus tokens and cost |
tool-row | Redraws finished tool-call rows in the terminal as one compact line with a Nerd Font icon, Gruvbox colors and a short stat |
image-preview | Thumbnails of the images a session writes, reads or generates: under the tool's row in the terminal, and in an /images pane |
self-audit | After each turn, a cached fork of the conversation audits Claude's own reply against the CLAUDE.md reporting rules and flags violations above the prompt |
context-diet | Trims the skill listing to what the project kind needs (off by default); /context-diet shows what was dropped and the tokens saved |
browser-cam | Live pane of the browser Claude drives: the latest screenshot from Claude in Chrome, the page URL and the recent actions |
prompt-coach | Holds back a typed prompt that looks ambiguous (no done-condition, unclear target) and offers guesses at what it meant, sending it as is, or adding one; on by default |
loopback | Feeds what self-audit, done-gate and sandbox-lens found back to Claude beside the next prompt, instead of only showing it to the person, skipping rules hit-rate found mostly wrong; off by default |
zukan | Error book: catches the errors Bash commands hit across sessions and repos, records what got past each one, and when one comes back tells Claude and the person how it was fixed last time |
pr-lamp | The current branch's pull request as a lamp: checks, review, unresolved threads and what is left before a merge, in the status line and a /pr pane; red checks and new review comments go to Claude beside the next prompt |
fog-map | Exploration map: the repository as a tree under fog, cleared where Claude read and marked where it edited, made a file or hit an error, with the importers an edit may have broken unseen; /map |
assertion-guard | Catches edits that weaken tests to pass: assertions removed, expectations loosened or rewritten, tests skipped, errors swallowed; asks Claude for the reason beside the edit |
negative-space | When an edit adds authorization, validation or error handling, lists the refusal and boundary-value tests no test touched this session covers; /negative-space |
mutation-tap | /mutate runs a mutation test limited to the lines changed since HEAD and lists the mutants the tests did not catch |
perf-delta | /perf compares a benchmark before and after (a saved baseline, or a git ref in a temporary worktree): median time, peak memory, spread, and whether the change beats the noise |
handoff-capsule | Before a compaction and at session end, keeps the goal, decisions, open items, commands to reproduce and files touched; the next session picks which parts to bring back |
port-lantern | The dev servers the session started: URL, port, whether each still listens, and their latest log lines, in the status line and /ports |
artifact-shelf | A shelf of what sessions made (CSV, PDF, HTML, video, patches…), tied to the call and request that made each; /shelf searches by kind, word and time |
focus-hud | One status line: goal, target, what is running and for how long, and what the session waits on; /focus |
side-effect-ledger | A ledger of what a git diff never shows (push, publish, deploy, PRs, API writes, messages) with outcome, how to undo, and whether the undo was checked; /ledger |
trust-label | Labels external text (web, issues, logs, browser pages, services) with its source beside the result and flags candidate injected instructions; a heuristic, never a safety verdict; /trust |
visual-diff | Compares browser screenshots of the same URL and size before and after an edit: side by side, a map of changed cells, regions, and the edits in between; /vdiff |
journey-trace | Records Claude's browser actions and the expected states as a replayable recipe; /journey md for a bug report, /journey e2e for a Playwright draft |
hit-rate | Gives each finding of done-gate, assertion-guard, self-audit and zukan a later verdict from what the session did next, keeps each rule's precision across sessions, and names the rules loopback stops feeding; /hit-rate; off by default |
token-lens | Checks an element's actual colours, spacing and type against the project's design tokens and lists off-token values with the nearest token; /token-lens <selector> |
At the prompt of a terminal session:
/plugin install <mod> --marketplace simota/claude-mods
From a shell:
claude plugin install <mod> --marketplace simota/claude-mods
This repository is private: installing needs read access on GitHub and a git credential for github.com on the machine (an SSH key, or gh auth login then gh auth setup-git).
Options are set with /plugin configure <mod>@simota-mods in a session, or from a shell:
echo '{"enabled":"true"}' | claude plugin configure <mod>@simota-mods --values-stdin
A change made with /plugin configure reloads the mod; one made from a shell applies after a restart. Bands above the prompt stack: each mod draws its row above what the others draw.
/agent-pane opens the pane: running subagents first with elapsed time, then finished ones with ✓/✗ and duration; a background run that was stopped shows ■ 停止. A background run that left the roster without a result shows ? 結果不明; a teammate or remote launch shows ↗ 起動済み(追跡外).agents: N running while any run.Works on its own, no command:
ls build, npm ci are not checks), and only when its exit status is the command's: the check is last, or only && follows it (npm test | tail does not count).--no-verify (or a prefix of it from --no-ver), and git commit -n / -nm, is denied, also inside subshells, $(…), backticks, bash -c "…", eval and wrappers such as sudo/timeout. The value of an option (-m "…"), a comment or a heredoc never triggers it. So is a git command that moves its hooks for the run (-c core.hooksPath=…, --config-env core.hooksPath=…) or carries a hook manager's off switch in its environment (HUSKY=0, LEFTHOOK=0, pre-commit's SKIP=…), or moves the hooks through config in its environment (GIT_CONFIG_KEY_n=core.hooksPath, GIT_CONFIG_PARAMETERS). ANSI-C quoting ($'--no-\x76erify'), env -S "…", bash -c -- '…' and a shell reading its script from stdin (bash <<< '…', a heredoc, echo '…' | sh) are read through. It is a best-effort guard: git aliases (git ci -n), a hook path written into config beforehand and an environment exported by an earlier command are not detected./sandbox-report lists the sandbox denials seen in Bash output, for this session and all time: denied hosts and paths, counts, last seen, an example command, and which setting would allow each./sandbox-report reset clears the record.127.0.0.1:3000), since an entry without a port opens every port.<sandbox_violations> block, and Operation not permitted lines a program printed about itself, are read. For a command that succeeded or was interrupted, the block is taken from where the engine puts it, apart from the command's own output, so one inside a shown file or page is ignored even when it ends the output. A failed command keeps only its error text: the block that ends it (before an agent-proxy note) is read, and there a block the command printed itself cannot be told from the harness's. Credentials in the example command (tokens, passwords, Authorization/Cookie and other credential headers such as X-API-Key, URL userinfo, keys in env assignments including run-together names such as PGPASSWORD, known key shapes) are masked. Each section shows the top 10; when the record is full (200), the least recently seen entry gives way.After a prompt is sent, up to three skills matching it show above the prompt (💡 skill候補: /coding-debug …), only ones that exist in the session. Nothing is invoked; 閉じる or the next prompt (a slash command included) clears it. A prompt that already names a skill (/name anywhere, or a hyphenated skill name such as coding-debug) gets no hint; built-in commands such as /clear and path segments such as src/review/ do not count as naming one. Plugin skills (plugin:name) are suggested by their full name.
#TODO(agent): <action> (after an optional quote or list bullet) in Claude's main-loop replies are collected per project (repo root or cwd). A mention inside a sentence, a table or inline code is not a TODO; subagent replies are ignored./todos opens the pane of this project's open items, each with a 完了 button./todos all lists every project's open items as text, marked as stored data.TODO(agent): N while the project has open items.A band with a sparkline of context use per turn and ctx 63% ▲4 · 126k · turn in 120k / out 8k · $1.23: the context size of the last request, then the whole turn's input/output (shown when the turn reported usage). The percentage turns yellow from 50% and red from 80%.
| Option | Default | |
|---|---|---|
band | true | Draw the band (samples are recorded either way) |
Finished tool calls are drawn as one compact line with a Nerd Font icon: Edit/Write path +A −D, Read with line count, Bash (red on error), Agent, web and MCP calls. The result block under a compacted row is hidden, so a call is one line; an errored result stays. A row another mod decorated (an image-preview thumbnail) is left as it is, result included. Runs Claude Code folds into one count line (Read 3 files, ran 2 shell commands) stay folded. Terminal only.
| Option | Default | |
|---|---|---|
compact | true | Off leaves every row and result as Claude Code draws them |
/images opens a pane, newest first; h/l step through them.sips after the tool result has gone back, so tool calls never wait on it. Converted files live in a private cache ($TMPDIR or ~/.cache), newest 50 kept. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps./tmp/a b/x.jpg) joins at most 8 preceding pieces and 4096 characters; the first 64 paths named are read, and of those the first 4 that exist are shown.| Option | Default | |
|---|---|---|
inline | true | Thumbnail under the tool row |
maxRows | 12 | Rows a thumbnail may take, 1 to 60 |
After each reply of at least minChars characters, one tool-less fork of the conversation (served mostly from the prompt cache), given the reply itself as quoted data, checks it against the CLAUDE.md rules: claims done without evidence, first line not the result, unverified assertions, bypassed checks, scope inflation. Only violations of the five rules whose quote is found in the reply are kept (a made-up quote or rule is dropped); the quote is compared past markdown marks, bullets, curly quotes, case, spacing and a closing full stop, and one shortened with … is found piece by piece. Violations show in red above the prompt; 詳細 or /self-audit opens the pane with quotes and the fork's token use. Results belong to the latest reply only: when it was not audited, the pane says why.
| Option | Default | |
|---|---|---|
enabled | true | |
minChars | 200 | Shorter replies are not audited |
Cost: one extra request per turn on the session's model, plus the reply text (up to 12,000 characters) as uncached input; a cold cache (after 5 minutes idle or a model switch) resends the whole conversation.
Off by default. When on, the skill listing is trimmed to what the project kind needs (Swift, Android, Rust, web, Python, Go, detected from the repo root and one level below). Coding, quality, planning and research skills are always kept; red-team, dynamic-*, persona and operation skills are dropped; image, design, ux-scan and brand-scan are dropped for Rust, Python and Go. Only the main loop's listing is reported. A dropped skill named in a prompt is still described for that prompt.
/context-diet shows the project kind, what was kept and dropped in the latest listing, and the tokens saved (estimate). A listing it cannot read reliably goes out unchanged./context-diet on|off|reset overrides the option for this session. Switching rebuilds the listing, so the prompt cache misses once.| Option | Default | |
|---|---|---|
enabled | false | |
keep | Comma list of names or prefix* never dropped; wins over drop | |
drop | Comma list of names or prefix* always dropped |
type ‹7文字›), since the field may be a password; a URL's secrets (token parameters, userinfo, key-shaped parts) are masked./cam toggles the pane; in a fullscreen terminal it opens by itself once per session, on the first browser call. Screenshots are decoded only while the pane is open, and written to a private per-session folder that is removed at session end. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps.| Option | Default | |
|---|---|---|
autoOpen | true | Open on the first browser call (fullscreen terminal) |
On by default (enabled: false turns it off). A typed prompt that looks ambiguous (no done-condition and an unclear target, or only evaluative words like いい感じ) is held back, and a band shows what is missing with up to three guesses at what it meant (judged along the lines of the clarify skill: the likeliest reading first, each a different deliverable with a checkable done-condition):
a / b / d: a guess goes into the prompt to edit and send (not held again)s: このまま送る sends it unchangede: 補足して送る puts it back with 完了条件: to fill in (not held again)q: 閉じる puts it back in the promptTyping another prompt instead sends that one and puts the held text back into the prompt. The held text is never lost: when a send fails or is dropped beneath, or putting words back fails, it stays held with its band (unless another prompt was held since) and its words are shown in a toast; a prompt still held at /clear goes straight back into the box, and one held when the session quits comes back at this project's next interactive session start. Words go into an empty box as they are, and are appended after a draft typed meanwhile on a line of their own, never over it.
Slash commands, prompts under 12 or over 1500 characters, prompts with any attachment or an @ mention, prompts sent by a plugin, and prompts typed while a turn runs are not judged. A timeout or error sends the prompt unchanged; the same text is not held twice (within the last 50 judged). Sending can wait up to timeoutMs.
| Option | Default | |
|---|---|---|
enabled | true | |
model | claude-haiku-5-5 | Judging model (alias or id) |
timeoutMs | 3000 | 500 to 3000 |
Off by default. When on, the new findings of the detector mods are attached to the next prompt typed at an idle prompt, as context only the model reads, so the correction does not wait for the person to retype it:
self-audit: the violations of the latest audited replydone-gate: edits with no passing check after them (again after a check passed and new edits were left unverified; 閉じる on its band takes the flag down, and nothing is fed)sandbox-lens: denials seen this session, each onceEach finding goes back once per session, marked as data that may be wrong for Claude to check before acting. A toast says how many went back and from where. Slash commands, hidden prompts, prompts sent by a plugin and prompts typed while a turn runs carry nothing. A detector that is not installed contributes nothing.
With hit-rate on, the rules it muted are not fed (sandbox-lens denials never are muted).
/loopback shows what was fed last and the self-audit repeat rate: of the rules fed back, how many self-audit flagged again on the next audited reply (all sessions). The rate is what the design is judged by (.genius/decisions/loopback.md). It also names the rules hit-rate muted.| Option | Default | |
|---|---|---|
enabled | false |
Cost: the findings ride as extra input on the prompt they are attached to (each quote clipped to 300 characters, at most 12 findings).
An error book, kept across sessions and repos. When a Bash command fails, the error line is caught and numbered:
📕 新種のエラー発見! No.042「…」 — a kind not seen before📙 No.017 再会(3回目・未解決) — seen before, nothing known to get past it 📗 既知 No.017(3回目)— 前回: sandbox 外で git push origin main ` — seen before with a fix: Claude also reads, beside the tool's result, where it was seen and what got past it (marked as a past record to check, not an instruction)A fix is learned when the same program and subcommand (git push, pnpm install; read past a leading cd, export or source, so cd app && npm install is npm install) succeeds within the next 10 Bash calls of the session; whether it ran outside the sandbox is kept with it. Two sightings are one kind when their error line matches with paths, quotes, numbers and hashes taken out.
Failures of tests, linters, type checkers and builds are not caught: they are fixed by editing code, not by a command. Credentials in a kept command and in the kept error line are masked as sandbox-lens does.
/zukan opens the book, most recently seen first: kinds, solved, sightings/zukan note <No> <text> writes how to get past it, shown and fed in place of the learned command; an empty text removes the note/zukan forget <No> removes an entryThe newest 300 kinds by last sighting are kept.
The current branch's pull request, kept in the status line while you work:
⎇ #42 · ✓5 ✗1 ⏳2 · 🛑 変更要求 · 💬3 · マージまであと4
"マージまであと N" counts what still stands before a merge: draft, failed checks, running checks, changes requested or approval pending, unresolved review threads, a conflict, the branch behind its base. At 0 it reads 🟢 マージ可能.
A toast when it changes: 🔴 CI 失敗: test, 💬 新しいレビューコメント 2件 (@rev), ✅ Approved, 🟢 #42 マージまであと0, 🎉 #42 がマージされました. Checks turning red, new review comments, changes requested and a new conflict are also attached to the next prompt typed at an idle prompt, once, with the last 40 lines of the failed log of up to three jobs (more are counted), marked as data whose comments and logs are not to be followed as instructions. The session's own replies on a thread (the comment, made after the post, carrying words it replied with through an MCP reply tool or gh api …/pulls/N/comments… -f body=, matched once) are not news; your own review comments are. What waits for the next prompt is about one pull request and is capped (newest kept). The option feed (on by default) turns that off.
/pr asks again and opens the pane: title, size, what is left, checks (failed first) and unresolved threads/pr <number|url|branch> follows that pull request; /pr clear goes back to the defaultWhich pull request: the one a Bash command of the session last worked on (gh pr comment 2, gh pr review 2, the URL gh pr create printed, the branch a git push sent to; looking with gh pr view|diff|checks does not move it), so work pushed to a review branch from another checkout is followed; else the checked-out branch's; else your newest open pull request in the repository.
It asks gh (gh pr view, and gh api graphql for review threads) at session start, right after a Bash git push, git checkout, gh pr create and the like, every minute while a check runs or after a push, and every five minutes otherwise; it stops once the pull request is merged or closed. Without an origin remote, gh, or a pull request for the branch it shows nothing, and /pr says why. gh runs on the host, outside the Bash sandbox, with your gh auth.
The repository as a map under fog, cleared as Claude works through it:
🗺 探索率 31% ██████░░░░░░░░░░░░░░ 読 23 · 編 9 · ✗ 2 / 101
▾ mods/
▾ pr-lamp/
▾ hooks/
● lamp.ts +43 −2 編集 4回
✗ register.tsx +59 −16 編集 6回 エラー 1 ★
▸ loopback/ ○●
░ agent-pane/ ░ browser-cam/ ░ context-diet/ …8
⚠ 編集したファイルを import しているが未読: mods/pr-lamp/tests/pr-lamp.test.ts
░ nothing touched yet, ○ read (Read, or cat, sed, grep… in Bash), ● edited (Edit, Write, NotebookEdit, sed -i), ✚ made by the session, ✗ named with a line number by a failed Bash command since its last edit, ★ the file touched last. A directory holding an edit, a new file or an error is open; one only read is closed with its marks beside it; untouched directories and files at a level share one fog row.
Two warnings, each a likely oversight: a file edited without being read, and a file that imports an edited one (JS/TS and Python, found by git grep) that was never opened. A very large repository's file list is cut to what one state value holds (3 M characters of paths), so it is still mapped, partly.
/map opens the pane🗺 31% · ●9 · ✗2 · ⚠1🗺 新しい区域を開拓: mods/zukan/)The map is the session's, subagents' calls included; files outside the repository ar
hooks/register.tsx 345 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { BrowserCamAction, BrowserCamFrame } from '../types'
5import { BROWSER_TOOL, cellBox, nextPage, pageFrom, pngDimensions, screenshotOf, shortName, sipsDimensions, summarize } from './cam'
6
7const PANE = 'browser-cam'
8const TITLE = 'ブラウザ'
9const KEEP = 30
10const SHOWN = 8
11const DIR_NAME = 'claude-browser-cam'
12// Frame files kept in the session's folder; the terminal reads only the newest.
13const FRAMES_KEPT = 4
14const ENDED_KEPT = 10
15// A session's folder left behind (the process was killed before session.end) goes after a day.
16const STALE_MS = 24 * 60 * 60 * 1000
17// A live session touches its folder this often, so one left idle for a day is not taken for killed.
18const HEARTBEAT_MS = 60 * 60 * 1000
19
20const frame = atom({ plugin: 'browser-cam', key: 'frame' } as const, null)
21const actions = atom({ plugin: 'browser-cam', key: 'actions' } as const, [])
22const page = atom({ plugin: 'browser-cam', key: 'page' } as const, {})
23const hasAutoOpened = atom({ plugin: 'browser-cam', key: 'hasAutoOpened' } as const, false)
24const lastSeq = atom({ plugin: 'browser-cam', key: 'seq' } as const, 0)
25const endedSessions = atom({ plugin: 'browser-cam', key: 'endedSessions' } as const, [] as string[])
26
27type Shot = { data: string; mime: string }
28type Pending = { shot: Shot; toolUseId: string; at: number; seq: number; sessionId: string }
29
30// The terminal's own word on fullscreen: another surface's viewport says nothing of it.
31let isFullscreen: boolean | undefined
32// Session folders made and checked by this load of the module, by session id.
33const sessionDirs = new Map<string, string>()
34let heartbeat: Timer | undefined
35// Results are read one at a time, in the order their calls finished.
36let queue: Promise<void> = Promise.resolve()
37// The newest screenshot seen while no pane was showing: kept, not decoded.
38let pending: Pending | undefined
39
40function enqueue($: EngineInterface, work: () => Promise<void>): Promise<void> {
41 queue = queue.then(work).catch(err => $.ui.log(`browser-cam: ${String(err)}`, { to: 'debug' }))
42 return queue
43}
44
45// A session id as a folder name: the same id always gives the same name, so a reload finds it again.
46const folderName = (sessionId: string) => sessionId.replace(/[^\w-]/g, '_')
47
48// TMPDIR when set, else ~/.cache: never a shared /tmp.
49async function rootDir($: EngineInterface): Promise<string | undefined> {
50 const tmp = (await $.env.get('TMPDIR'))?.replace(/\/+$/, '')
51 const home = (await $.env.get('HOME'))?.replace(/\/+$/, '')
52 return tmp !== undefined && tmp !== '' ? tmp : home !== undefined && home !== '' ? `${home}/.cache` : undefined
53}
54
55// `<parent>/<name>` when it is already a real directory (not a link) right where the parent resolves.
56async function checkedChild($: EngineInterface, parent: string, name: string): Promise<string | undefined> {
57 const [where, above] = await Promise.all([
58 $.fs.stat(`${parent}/${name}`, { resolve: true }).catch(() => undefined),
59 $.fs.stat(parent, { resolve: true }).catch(() => undefined),
60 ])
61 if (where?.kind !== 'dir' || where.isLink || above?.realPath === undefined) return undefined
62 return where.realPath === `${above.realPath}/${name}` ? where.realPath : undefined
63}
64
65/**
66 * `<parent>/<name>` as a folder only this user can reach: made 0700, then checked to be
67 * a real directory of ours (not a link, owned by us) right where the parent resolves.
68 */
69async function privateChild($: EngineInterface, parent: string, name: string): Promise<string | undefined> {
70 const made = await $.process
71 .run(['/bin/sh', '-c', 'umask 077 && mkdir -p -m 700 -- "$1" && [ -d "$1" ] && [ ! -L "$1" ] && [ -O "$1" ] && chmod 700 -- "$1"', 'sh', `${parent}/${name}`])
72 .catch(() => undefined)
73 if (made?.exitCode !== 0) return undefined
74 return checkedChild($, parent, name)
75}
76
77const hasEnded = async ($: EngineInterface, sessionId: string) => (await read($, endedSessions)).includes(sessionId)
78
79// Each hour while this process keeps a folder, its mtime is moved on: another session's sweep
80// reads a folder untouched for a day as a killed session's, however idle this one is.
81function keepAlive($: EngineInterface): void {
82 heartbeat ??= $.clock.every(HEARTBEAT_MS, () => {
83 if (sessionDirs.size === 0) {
84 heartbeat?.cancel()
85 heartbeat = undefined
86 return
87 }
88 for (const dir of sessionDirs.values()) void $.process.run(['touch', '-c', '--', dir]).catch(() => undefined)
89 })
90}
91
92// One folder per session, derived from its id. An ended session's folder is never made again.
93async function framesDir($: EngineInterface, sessionId: string): Promise<string | undefined> {
94 if (await hasEnded($, sessionId)) return undefined
95 const known = sessionDirs.get(sessionId)
96 if (known !== undefined) return known
97 const parent = await rootDir($)
98 if (parent === undefined) return undefined
99 const base = await privateChild($, parent, DIR_NAME)
100 if (base === undefined) return undefined
101 const dir = await privateChild($, base, folderName(sessionId))
102 if (dir !== undefined) {
103 sessionDirs.set(sessionId, dir)
104 keepAlive($)
105 }
106 return dir
107}
108
109// The plugin's base folder as it already stands; nothing is made.
110async function existingBase($: EngineInterface): Promise<string | undefined> {
111 const parent = await rootDir($)
112 return parent === undefined ? undefined : checkedChild($, parent, DIR_NAME)
113}
114
115// The folder is found again from the id alone, so a reload since it was made still removes it.
116async function removeSessionDir($: EngineInterface, sessionId: string): Promise<void> {
117 sessionDirs.delete(sessionId)
118 const base = await existingBase($)
119 if (base === undefined) return
120 const dir = await checkedChild($, base, folderName(sessionId))
121 if (dir !== undefined) await $.process.run(['rm', '-rf', '--', dir]).catch(() => undefined)
122}
123
124// Folders of sessions that never reached session.end (a killed process), once a day old.
125async function sweepStale($: EngineInterface, currentId: string): Promise<void> {
126 const base = await existingBase($)
127 if (base === undefined) return
128 const entries = await $.fs.list(base).catch(() => [])
129 const cutoff = (await $.clock.now()) - STALE_MS
130 for (const entry of entries) {
131 if (entry.kind !== 'dir' || entry.name === folderName(currentId)) continue
132 const stat = await $.fs.stat(`${base}/${entry.name}`, { resolve: true }).catch(() => undefined)
133 if (stat === undefined || stat.mtimeMs >= cutoff) continue
134 const dir = await checkedChild($, base, entry.name)
135 if (dir !== undefined) await $.process.run(['rm', '-rf', '--', dir]).catch(() => undefined)
136 }
137}
138
139// Never onto a file already there (`set -C`): every frame takes a fresh name.
140async function writeNew($: EngineInterface, path: string, base64: string): Promise<boolean> {
141 const ran = await $.process.run(['/bin/sh', '-c', 'set -C && base64 -d > "$1"', 'sh', path], { stdin: base64 }).catch(() => undefined)
142 return ran?.exitCode === 0
143}
144
145async function pruneFrames($: EngineInterface, dir: string): Promise<void> {
146 await $.process
147 .run(['/bin/sh', '-c', 'cd -- "$1" && ls -t | tail -n +"$2" | while IFS= read -r f; do rm -f -- "$f"; done', 'sh', dir, String(FRAMES_KEPT + 1)])
148 .catch(() => undefined)
149}
150
151async function drawable($: EngineInterface, item: Pending): Promise<BrowserCamFrame | undefined> {
152 const base: BrowserCamFrame = { toolUseId: item.toolUseId, seq: item.seq, sessionId: item.sessionId, mime: item.shot.mime, at: item.at }
153 if (await hasEnded($, item.sessionId)) return undefined
154 const dir = await framesDir($, item.sessionId)
155 if (dir === undefined) return { ...base, note: '画像の保存先を用意できませんでした' }
156 // The call's order number is kept in state, so a name is never reused, reload or not.
157 const stem = `${dir}/${item.seq}-${item.at}`
158 const png = `${stem}.png`
159 const source = { file: png, format: 'png' as const, generation: item.seq }
160
161 if (item.shot.mime === 'image/png') {
162 if (!(await writeNew($, png, item.shot.data))) return { ...base, note: '画像を書き出せませんでした' }
163 await pruneFrames($, dir)
164 return { ...base, ...pngDimensions(item.shot.data), source }
165 }
166 const raw = `${stem}.raw`
167 if (!(await writeNew($, raw, item.shot.data))) return { ...base, note: '画像を書き出せませんでした' }
168 const converted = await $.process.run(['sips', '-s', 'format', 'png', raw, '--out', png], { timeoutMs: 15_000 }).catch(() => undefined)
169 if (converted?.exitCode !== 0) return { ...base, note: `${item.shot.mime} を PNG に変換できませんでした` }
170 const measured = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', png]).catch(() => undefined)
171 await pruneFrames($, dir)
172 return { ...base, ...(measured?.exitCode === 0 ? sipsDimensions(measured.stdout) : undefined), source }
173}
174
175async function draw($: EngineInterface, item: Pending): Promise<void> {
176 const latest = await drawable($, item)
177 if (latest === undefined) return
178 // A slower frame never covers a later one; the order survives a reload, being in state.
179 await update($, frame, old => (old !== null && old.seq > latest.seq ? old : latest))
180}
181
182const isPaneOpen = async ($: EngineInterface) => (await $.ui.panes()).some(pane => pane.id === PANE)
183
184async function ingest(
185 $: EngineInterface,
186 call: { toolUseId: string; tool: string; summary: string; seq: number; isOpening: boolean; sessionId: string },
187 ran: { isOk: boolean; result: unknown; seen: { url?: string; title?: string } },
188): Promise<void> {
189 // Work queued before its session ended writes nothing into the next one.
190 if (await hasEnded($, call.sessionId)) return
191 const at = await $.clock.now()
192 const action: BrowserCamAction = { id: call.toolUseId, tool: call.tool, summary: call.summary, isOk: ran.isOk, at }
193 await update($, actions, list => [...list.filter(one => one.id !== action.id), action].slice(-KEEP))
194 if (ran.seen.url !== undefined || ran.seen.title !== undefined) await update($, page, old => nextPage(old, ran.seen))
195
196 const shot = screenshotOf(ran.result)
197 if (shot === undefined) return
198 const item: Pending = { shot, toolUseId: call.toolUseId, at, seq: call.seq, sessionId: call.sessionId }
199 // No pane to draw into: keep the newest screenshot as it came, decode nothing.
200 if (!call.isOpening && !(await isPaneOpen($))) {
201 if (pending === undefined || pending.seq < item.seq) pending = item
202 return
203 }
204 pending = undefined
205 await draw($, item)
206}
207
208// Opened unasked: a refusal goes to the debug log, never into the tool call.
209async function openUnasked($: EngineInterface): Promise<void> {
210 await $.ui.open({ id: PANE, title: TITLE }).catch(err => $.ui.log(`browser-cam: ${String(err)}`, { to: 'debug' }))
211}
212
213export const register: Register = (on, options) => {
214 const isAutoOpen = options.autoOpen !== false
215
216 on('session.start', async ($, e, next) => {
217 await $.command.register({ name: 'cam', description: 'ブラウザ操作の画面と履歴を表示するペインを開閉する' })
218 // A new session shows nothing of an earlier one; a hot reload (session.start again,
219 // same session) keeps its own frame.
220 const sessionId = await $.session.id()
221 pending = undefined
222 await update($, frame, old => (old !== null && old.sessionId !== sessionId ? null : old))
223 await sweepStale($, sessionId)
224 // After a reload this load has made nothing, yet the session's folder may be there already.
225 const base = await existingBase($)
226 const dir = base === undefined || (await hasEnded($, sessionId)) ? undefined : await checkedChild($, base, folderName(sessionId))
227 if (dir !== undefined) {
228 sessionDirs.set(sessionId, dir)
229 keepAlive($)
230 }
231 return next(e)
232 })
233
234 on('session.end', async ($, e, next) => {
235 // A /clear ends the conversation with no session.start after it: what it showed goes now.
236 await update($, endedSessions, list => [...list.filter(id => id !== e.sessionId), e.sessionId].slice(-ENDED_KEPT))
237 pending = undefined
238 await update($, frame, () => null)
239 await update($, actions, () => [])
240 await update($, page, () => ({}))
241 // The session's frames go with it; the folder is found from the id and checked before removal.
242 await removeSessionDir($, e.sessionId)
243 return next(e)
244 })
245
246 on('command.run', { command: 'cam' }, async $ => {
247 if (await isPaneOpen($)) {
248 await $.ui.close({ id: PANE })
249 return { text: 'ブラウザペインを閉じました。' }
250 }
251 await $.ui.open({ id: PANE, title: TITLE })
252 const waiting = pending
253 pending = undefined
254 if (waiting !== undefined) void enqueue($, () => draw($, waiting))
255 return { text: 'ブラウザペインを開きました。' }
256 })
257
258 on('tool.call', async ($, e, next) => {
259 const tool = String(e.tool)
260 if (!BROWSER_TOOL.test(tool)) return next(e)
261
262 let isOpening = false
263 if (isAutoOpen && isFullscreen === true && !(await read($, hasAutoOpened))) {
264 await update($, hasAutoOpened, () => true)
265 isOpening = true
266 void openUnasked($)
267 }
268
269 const sessionId = await $.session.id()
270 const input = e as unknown as Record<string, unknown>
271 const ran = await next(e)
272 const result = ran.deny === undefined ? ran.result : undefined
273 const isMcpError = typeof result === 'object' && result !== null && (result as { isError?: unknown }).isError === true
274 const name = shortName(tool)
275 const seq = await update($, lastSeq, n => n + 1)
276 const call = { toolUseId: e.tool_use_id, tool: name, summary: summarize(input), seq, isOpening, sessionId }
277 const outcome = {
278 isOk: ran.deny === undefined && ran.isError !== true && !isMcpError,
279 result,
280 seen: pageFrom(name, input, ran.text ?? ''),
281 }
282 // Read the screenshot from a timer, so decoding and conversion never hold the result back from the model.
283 $.clock.after(0, () => enqueue($, () => ingest($, call, outcome)))
284 return ran
285 }).catch(($, e, next) => next(e)) // an observer: a failure here never changes the call
286
287 // Learned from the band's viewport, not from every command.run: a hook there would
288 // put this plugin's name on the output of every slash command.
289 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
290 if (e.surface === 'terminal' && e.viewport?.isFullscreen !== undefined) isFullscreen = e.viewport.isFullscreen
291 return next(e)
292 })
293
294 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
295 const { Box, Text } = $.ui.resolve(e)
296 const shown = await read($, frame)
297 const list = await read($, actions)
298 const where = await read($, page)
299
300 if (shown === null && list.length === 0) {
301 return <Text dimColor>まだブラウザ操作はありません。</Text>
302 }
303
304 const recent = list.slice(-SHOWN).reverse()
305 const location = [where.title, where.url].filter(Boolean).join(' — ')
306 const history = (
307 <Box flexDirection="column">
308 {location !== '' && (
309 <Text dimColor wrap="truncate-end">
310 🌐 {location}
311 </Text>
312 )}
313 {recent.map(one => (
314 <Text key={one.id} wrap="truncate-end" color={one.isOk ? undefined : 'red'}>
315 {one.isOk ? '✓' : '✗'} {one.tool}
316 {one.summary !== '' ? `: ${one.summary}` : ''}
317 </Text>
318 ))}
319 </Box>
320 )
321
322 const noteOf = (one: BrowserCamFrame) => one.note ?? `${one.mime}${one.width !== undefined ? ` · ${one.width}×${one.height}` : ''}`
323
324 if (e.surface === 'terminal' && shown?.source !== undefined) {
325 const { Image } = $.ui.resolve(e)
326 // The pane's own room, less the history under the picture and the location line.
327 const rows = Math.max(4, e.props.scroll.bodyRows - SHOWN - 2)
328 const box = cellBox(shown, Math.max(8, e.props.bodyColumns - 1), rows)
329 return (
330 <Box flexDirection="column">
331 <Image key="view" source={shown.source} columns={box.columns} rows={box.rows} alt={`スクリーンショット (${noteOf(shown)})`} />
332 {history}
333 </Box>
334 )
335 }
336
337 return (
338 <Box flexDirection="column">
339 {shown !== null && <Text dimColor>📷 最新のスクリーンショット: {noteOf(shown)}</Text>}
340 {history}
341 </Box>
342 )
343 })
344}
345hooks/cam.ts 241 lines1// Pure helpers: reading a browser tool's call and result, sizing the picture in cells.
2
3import { maskedOf } from './mask'
4
5// Claude in Chrome, the desktop app's browser, and that browser reached from a cloud session.
6export const BROWSER_TOOL = /^mcp__(?:remote-devices__)?(?:claude-in-chrome|Claude_Browser)__/
7
8export function shortName(tool: string): string {
9 return tool.replace(BROWSER_TOOL, '')
10}
11
12// Marks that belong to the character before them: combining marks, variation
13// selectors, the keycap mark, skin tones and tag characters.
14const isExtend = (cp: number) =>
15 (cp >= 0x0300 && cp <= 0x036f) ||
16 (cp >= 0x1ab0 && cp <= 0x1aff) ||
17 (cp >= 0x20d0 && cp <= 0x20ff) ||
18 (cp >= 0xfe00 && cp <= 0xfe0f) ||
19 (cp >= 0xfe20 && cp <= 0xfe2f) ||
20 (cp >= 0x1f3fb && cp <= 0x1f3ff) ||
21 (cp >= 0xe0020 && cp <= 0xe007f) ||
22 (cp >= 0xe0100 && cp <= 0xe01ef)
23const isRegional = (cp: number) => cp >= 0x1f1e6 && cp <= 0x1f1ff
24const ZWJ = 0x200d
25
26/**
27 * User-perceived characters where the runtime has no Intl.Segmenter: a ZWJ sequence
28 * (👩💻), a flag (two regional indicators), a keycap or a base with its marks stays whole.
29 */
30export function graphemesFallback(text: string): string[] {
31 const out: string[] = []
32 let isAfterJoiner = false
33 for (const ch of text) {
34 const cp = ch.codePointAt(0) ?? 0
35 const prev = out.at(-1)
36 const prevCp = prev?.codePointAt(0) ?? 0
37 const isFlagHalf = isRegional(cp) && prev !== undefined && Array.from(prev).length === 1 && isRegional(prevCp)
38 if (prev !== undefined && (isAfterJoiner || cp === ZWJ || isExtend(cp) || isFlagHalf)) out[out.length - 1] = prev + ch
39 else out.push(ch)
40 isAfterJoiner = cp === ZWJ
41 }
42 return out
43}
44
45export function graphemes(text: string): string[] {
46 if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
47 return Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text), part => part.segment)
48 }
49 return graphemesFallback(text)
50}
51
52// Cut between whole characters, so an emoji (a ZWJ sequence or a flag too) is never split.
53export function clip(text: string, max: number): string {
54 const flat = graphemes(text.replace(/\s+/g, ' ').trim())
55 return flat.length > max ? `${flat.slice(0, max - 1).join('')}…` : flat.join('')
56}
57
58/**
59 * The call's key input in one short line: action, coordinate, url, typed text or query.
60 * Text typed or filled in is shown as its length only: the pane cannot tell a password field,
61 * and the screenshot beside it shows such a field as dots. A URL's secrets are masked.
62 */
63export function summarize(input: Record<string, unknown>): string {
64 const parts: string[] = []
65 if (typeof input.action === 'string') parts.push(input.action)
66 const at = input.coordinate
67 if (Array.isArray(at) && at.length === 2 && at.every(n => typeof n === 'number')) parts.push(`(${at[0]},${at[1]})`)
68 if (typeof input.url === 'string') parts.push(clip(maskedOf(input.url), 60))
69 for (const field of ['text', 'value']) {
70 const value = input[field]
71 if (typeof value !== 'string' || value === '') continue
72 // A key action's text names keys (`ctrl+a`, `Enter`), not what was typed.
73 parts.push(field === 'text' && input.action === 'key' ? `"${clip(value, 30)}"` : `‹${graphemes(value).length}文字›`)
74 }
75 if (typeof input.query === 'string' && input.query !== '') parts.push(`"${clip(maskedOf(input.query), 30)}"`)
76 return parts.join(' ')
77}
78
79type Block = Record<string, unknown>
80
81function blocksOf(result: unknown): Block[] {
82 const content = Array.isArray(result)
83 ? result
84 : typeof result === 'object' && result !== null && Array.isArray((result as Block).content)
85 ? ((result as Block).content as unknown[])
86 : []
87 return content.filter((block): block is Block => typeof block === 'object' && block !== null)
88}
89
90/**
91 * The last image block of a tool's result: MCP's `{ type: 'image', data, mimeType }`,
92 * or the Messages API's `{ type: 'image', source: { data, media_type } }`.
93 */
94export function screenshotOf(result: unknown): { data: string; mime: string } | undefined {
95 let found: { data: string; mime: string } | undefined
96 for (const block of blocksOf(result)) {
97 if (block.type !== 'image') continue
98 const source = typeof block.source === 'object' && block.source !== null ? (block.source as Block) : undefined
99 const data = typeof block.data === 'string' ? block.data : source?.data
100 const mime = typeof block.mimeType === 'string' ? block.mimeType : source?.media_type
101 if (typeof data === 'string' && data !== '') found = { data, mime: typeof mime === 'string' ? mime : 'image/png' }
102 }
103 return found
104}
105
106// The tools whose own answer describes the tab they act on. Every other result
107// (read_page, get_page_text, find, javascript) carries page content the page controls.
108const SINGLE_TAB_TOOLS = /^(?:navigate|tabs_create(?:_mcp)?)$/
109const TAB_LIST_TOOLS = /^tabs_context(?:_mcp)?$/
110// A single-tab answer's header: its first lines, and only exact `URL:` / `Title:` lines.
111const HEADER_LINES = 3
112const URL_LINE = /^URL: (https?:\/\/\S+)$/
113const TITLE_LINE = /^Title: (.+)$/
114// How a text listing marks the tab being driven (a `*` bullet is a plain list item, not a mark).
115const ACTIVE_MARK = /^\s*[→▶]\s|\((?:active|current|selected)\)|\b(?:active|current|selected)\s*[:=]\s*true\b/i
116const ACTIVE_KEYS = ['active', 'current', 'selected', 'isActive'] as const
117const ITEM_START = /^\s*(?:[-*→▶]|\d+[.)])\s/
118
119type Page = { url?: string; title?: string }
120
121const httpUrl = (value: unknown) => (typeof value === 'string' && /^https?:\/\/\S+$/i.test(value) ? value : undefined)
122
123function headerOf(lines: string[]): Page {
124 const page: Page = {}
125 for (const line of lines) {
126 const url = URL_LINE.exec(line)?.[1]
127 const title = TITLE_LINE.exec(line)?.[1]?.trim()
128 if (url !== undefined && page.url === undefined) page.url = url
129 if (title !== undefined && title !== '' && page.title === undefined) page.title = title
130 }
131 return page
132}
133
134function activeInJson(text: string): Page | undefined {
135 let parsed: unknown
136 try {
137 parsed = JSON.parse(text)
138 } catch {
139 return undefined
140 }
141 const tabs: unknown = Array.isArray(parsed) ? parsed : typeof parsed === 'object' && parsed !== null ? (parsed as { tabs?: unknown }).tabs : undefined
142 if (!Array.isArray(tabs)) return {}
143 for (const tab of tabs) {
144 if (typeof tab !== 'object' || tab === null) continue
145 const fields = tab as Record<string, unknown>
146 if (!ACTIVE_KEYS.some(key => fields[key] === true)) continue
147 const url = httpUrl(fields.url)
148 const title = typeof fields.title === 'string' && fields.title.trim() !== '' ? fields.title.trim() : undefined
149 return { ...(url !== undefined ? { url } : {}), ...(title !== undefined ? { title } : {}) }
150 }
151 return {}
152}
153
154// The tab a listing marks as active, or nothing: an unmarked listing names no tab in particular.
155function activeTabOf(text: string): Page {
156 const fromJson = activeInJson(text)
157 if (fromJson !== undefined) return fromJson
158 const lines = text.split('\n')
159 const at = lines.findIndex(line => ACTIVE_MARK.test(line))
160 if (at < 0) return {}
161 // The marked line and the lines under it, up to a blank line or the next listed tab.
162 const block = [lines[at] ?? '']
163 for (const line of lines.slice(at + 1, at + 4)) {
164 if (line.trim() === '' || ITEM_START.test(line)) break
165 block.push(line)
166 }
167 const bare = block.map(line =>
168 line
169 .replace(/^\s*(?:[-*→▶]|\d+[.)])?\s*/, '')
170 .replace(/\s*\((?:active|current|selected)\)\s*$/i, ''),
171 )
172 return headerOf(bare)
173}
174
175/**
176 * The page a call leads to: the url the call itself was given, then an exact `URL:` /
177 * `Title:` line in the first lines of a single-tab tool's own answer, or the tab a
178 * listing marks as active. Never page text: a page's words could otherwise name any address.
179 */
180export function pageFrom(tool: string, input: Record<string, unknown>, text: string): Page {
181 if (TAB_LIST_TOOLS.test(tool)) return activeTabOf(text)
182 if (!SINGLE_TAB_TOOLS.test(tool)) return {}
183 const header = headerOf(text.split('\n').slice(0, HEADER_LINES))
184 const url = header.url ?? (tool === 'navigate' ? httpUrl(input.url) : undefined)
185 return { ...(url !== undefined ? { url } : {}), ...(header.title !== undefined ? { title: header.title } : {}) }
186}
187
188/** The page as it stands after a call: a new address drops the old address's title. */
189export function nextPage(old: { url?: string; title?: string }, shown: { url?: string; title?: string }): { url?: string; title?: string } {
190 // The page line is kept and drawn masked: a callback's token or a URL's userinfo stays out.
191 const seen = shown.url === undefined ? shown : { ...shown, url: maskedOf(shown.url) }
192 if (seen.url !== undefined && seen.url !== old.url) return { url: seen.url, ...(seen.title !== undefined ? { title: seen.title } : {}) }
193 return { ...old, ...(seen.title !== undefined ? { title: seen.title } : {}) }
194}
195
196/** Width and height from the first bytes of a PNG (signature + IHDR), base64. */
197export function pngDimensions(base64: string): { width: number; height: number } | undefined {
198 let bytes: string
199 try {
200 bytes = atob(base64.slice(0, 32))
201 } catch {
202 return undefined
203 }
204 if (bytes.length < 24 || bytes.slice(1, 4) !== 'PNG' || bytes.slice(12, 16) !== 'IHDR') return undefined
205 const u32 = (at: number) =>
206 ((bytes.charCodeAt(at) << 24) | (bytes.charCodeAt(at + 1) << 16) | (bytes.charCodeAt(at + 2) << 8) | bytes.charCodeAt(at + 3)) >>> 0
207 const width = u32(16)
208 const height = u32(20)
209 return width > 0 && height > 0 ? { width, height } : undefined
210}
211
212/** `pixelWidth: 640` / `pixelHeight: 480` lines of `sips -g`. */
213export function sipsDimensions(stdout: string): { width: number; height: number } | undefined {
214 const width = Number(/pixelWidth:\s*(\d+)/.exec(stdout)?.[1])
215 const height = Number(/pixelHeight:\s*(\d+)/.exec(stdout)?.[1])
216 return width > 0 && height > 0 ? { width, height } : undefined
217}
218
219const clamp = (n: number, low: number, high: number) => Math.min(high, Math.max(low, Math.round(n)))
220
221/**
222 * The cell box a picture keeps its shape in: a terminal cell is about twice as
223 * tall as it is wide, and the Image element stretches to fill its box. Width first:
224 * a screenshot is wider than tall, so it takes the pane's columns.
225 */
226export function cellBox(
227 size: { width?: number; height?: number },
228 maxColumns: number,
229 maxRows: number,
230): { columns: number; rows: number } {
231 const columnsCap = clamp(maxColumns, 1, 255)
232 const rowsCap = clamp(maxRows, 1, 255)
233 if (size.width === undefined || size.height === undefined) {
234 return { columns: columnsCap, rows: Math.min(rowsCap, clamp(columnsCap / 3, 1, 255)) }
235 }
236 const ratio = size.width / size.height
237 const rows = columnsCap / (2 * ratio)
238 if (rows <= rowsCap) return { columns: columnsCap, rows: clamp(rows, 1, 255) }
239 return { columns: clamp(rowsCap * 2 * ratio, 1, 255), rows: rowsCap }
240}
241hooks/mask.ts 101 lines1// Masking credentials in a command before it is kept, as sandbox-lens does (copied from its
2// parse.ts: mods share no code).
3
4const EXAMPLE_CHARS = 120
5
6// Name segments that mark a value as a credential (`ANTHROPIC_API_KEY`, `x-access-token`, `db_pass`).
7const SECRET_WORDS = new Set([
8 'key', 'apikey', 'token', 'secret', 'password', 'passwd', 'pass', 'pwd',
9 'auth', 'cookie', 'session', 'credential', 'credentials',
10])
11// Words that mark a credential even inside a camelCase name (`accessToken`, `clientSecret`, `APIKey`),
12// and endings that mark one run together (`PGPASSWORD`). `key` and `session` are left out here:
13// `primaryKey` and `sessionId` are ids, not credentials.
14const STRONG_WORDS = new Set(['apikey', 'token', 'secret', 'password', 'passwd', 'credential', 'credentials'])
15const SECRET_ENDINGS = ['password', 'passwd', 'token', 'secret', 'apikey']
16const isStrongName = (name: string) =>
17 name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().split(/[_-]/).some(part => STRONG_WORDS.has(part)) ||
18 SECRET_ENDINGS.some(end => name.toLowerCase().replace(/[_-]/g, '').endsWith(end))
19const isSecretName = (name: string) => name.toLowerCase().split(/[_-]/).some(part => SECRET_WORDS.has(part)) || isStrongName(name)
20
21// A value: quoted whole, or up to whitespace or a shell separator.
22const VALUE = String.raw`(?:"[^"]*"|'[^']*'|[^\s&;|'"]+)`
23
24type Replacer = (substring: string, ...groups: string[]) => string
25type Mask = [RegExp, string | Replacer]
26
27// NAME=value. The name is bounded so a long run such as `a-a-a-…` is scanned in linear time, not once per hyphen.
28const NAME_VALUE = new RegExp(String.raw`\b([A-Za-z][\w-]{0,127})=(${VALUE})`, 'g')
29const maskName: Replacer = (whole, name = '') => (isSecretName(name) ? `${name}=***` : whole)
30// One level into the value, not recursively: `a=a=a=…` must not grow the stack.
31const maskNameValue: Replacer = (whole, name = '', value = '') =>
32 isSecretName(name) ? `${name}=***` : value.includes('=') ? `${name}=${value.replace(NAME_VALUE, maskName)}` : whole
33
34// Credentials a command line may carry, each kept to its name.
35const SECRETS: Mask[] = [
36 // URL userinfo: user:password@ keeps the user, a token alone@ goes whole; host and path stay.
37 // The scheme is bounded, as the NAME=value name below is, to keep a long hyphenated run linear.
38 [/(\b[a-z][\w+.-]{0,31}:\/\/)([^/\s:@'"]+):([^/\s@'"]+)@/gi, '$1$2:***@'],
39 [/(\b[a-z][\w+.-]{0,31}:\/\/)([^/\s:@'"*]+)@/gi, '$1***@'],
40 // Headers: Authorization keeps its scheme, Cookie loses every pair.
41 [/(authorization:\s*)(?:(bearer|basic|token)\s+)?[^\s'"]+/gi, (_, head = '', scheme = '') => `${head}${scheme ? `${scheme} ` : ''}***`],
42 [/(\bcookie:\s*)[^\s'";]+(?:;\s*[^\s'";]+)*;?/gi, '$1***'],
43 [/\b(bearer)\s+(?!\*\*\*)[\w.~+/=-]+/gi, '$1 ***'],
44 // Any other header a request sends whose name reads as a credential (-H "X-API-Key: x"); only
45 // as -H/--header, so an error line such as `Unexpected token: '<'` is left as it is. A quoted
46 // value goes whole up to its closing quote (`X-Auth: Token abc`); an unquoted header is one shell
47 // word with no space after the colon, so `grep -H password: config.yml` keeps its file name.
48 [
49 /((?:^|\s)(?:-H|--header)(?:=|\s*))(?:(["'])([\w-]+)(:\s*)(?:(?!\2)[^\n])+|([\w-]+)(:)[^\s'"]+)/g,
50 (whole, head = '', quote = '', quotedName = '', quotedColon = '', name = '', colon = '') => {
51 if (quote !== '') return isSecretName(quotedName) ? `${head}${quote}${quotedName}${quotedColon}***` : whole
52 return isSecretName(name) ? `${head}${name}${colon}***` : whole
53 },
54 ],
55 // -u user:pass, -uuser:pass, --user=user:pass, -u "user:pass"
56 [/(\s(?:-u|--user)(?:=|\s+)?)(["']?)([^\s:'"]+):([^\s'"]+)/g, '$1$2$3:***'],
57 // --password x, --token=x, --client-secret "x"
58 [
59 new RegExp(String.raw`(--(?:client-secret|api-?key|secret|token|auth-token|access-token|password|passwd)(?:=|\s+))${VALUE}`, 'gi'),
60 '$1***',
61 ],
62 // NAME=value where the name reads as a credential, quoted values too (env assignments, query strings).
63 // A value that is itself NAME=value (kubectl --from-literal=password=x) is looked into.
64 [NAME_VALUE, maskNameValue],
65 // "name": "value" (JSON in an output line) where the name reads as a credential.
66 // A bare "key" or "session" is an id there (a path, an S3 key), not a credential.
67 [/("([\w-]+)"\s*:\s*)"[^"]*"/g, (whole, head = '', name = '') => (isSecretName(name) && !/^(?:key|session)$/i.test(name) ? `${head}"***"` : whole)],
68 // Bare tokens with a known shape.
69 [/\b(sk-(?:ant-)?)[\w-]{16,}/g, '$1***'],
70 [/\b(gh[pousr]_)[A-Za-z0-9]{10,}/g, '$1***'],
71 [/\b(github_pat_)\w{10,}/g, '$1***'],
72 [/\b(xox[abprs]-)[\w-]{8,}/g, '$1***'],
73 [/\bAKIA[0-9A-Z]{16}\b/g, 'AKIA***'],
74 [/\b(glpat-)[\w-]{20,}/g, '$1***'],
75 [/\b(npm_)[A-Za-z0-9]{36}\b/g, '$1***'],
76 [/\b((?:sk|rk)_(?:live|test)_)[A-Za-z0-9]{10,}/g, '$1***'],
77 [/\bAIza[\w-]{35}\b/g, 'AIza***'],
78 // Started only where no word or hyphen comes before, so `eyJ-eyJ-…` is not rescanned from each one.
79 [/(?<![\w-])eyJ[\w-]{8,}\.eyJ[\w-]{8,}\.[\w-]*/g, 'eyJ***'],
80]
81
82// Programs whose -p takes a password: attached (mysql -pX) or as the next word (sshpass -p X, docker login -p X).
83const ATTACHED_P = /\b(?:mysql|mariadb|mysqldump|mysqladmin)\b/
84// The words before `login` are bounded, so a long run of `docker docker …` stays linear.
85const SPACED_P = /\bsshpass\b|\b(?:docker|podman|nerdctl|helm)\b(?:\s+\S+){0,8}?\s+login\b/
86
87/** The text with the credentials it carries masked: a command, or a line of its output. */
88export function maskedOf(text: string): string {
89 let masked = SECRETS.reduce((all, [pattern, mask]) => all.replace(pattern, mask as Replacer), text)
90 if (ATTACHED_P.test(masked)) masked = masked.replace(/(\s-p)(?!\s)\S+/g, '$1***')
91 if (SPACED_P.test(masked)) masked = masked.replace(/(\s-p\s+)\S+/g, '$1***')
92 return masked
93}
94
95/** The command as an example: credentials masked, cut to a line. */
96export function exampleOf(command: string): string {
97 const masked = maskedOf(command).replace(/ {2,}/g, ' ')
98 return masked.length > EXAMPLE_CHARS ? `${masked.slice(0, EXAMPLE_CHARS)}…` : masked
99}
100
101types/index.d.ts 53 lines1/**
2 * What the pane's Image draws: a PNG file this plugin wrote into its private,
3 * per-session folder. The bytes themselves never sit in state.
4 */
5export type BrowserCamSource = { file: string; format: 'png'; generation: number }
6
7export type BrowserCamFrame = {
8 /** The tool call whose result held the screenshot. */
9 toolUseId: string
10 /** The order the call finished in: a frame never replaces a later one. */
11 seq: number
12 /** The session whose call took it: another session never shows it. */
13 sessionId: string
14 /** Absent when the screenshot could not be made drawable; `note` says why. */
15 source?: BrowserCamSource
16 width?: number
17 height?: number
18 /** The screenshot's media type as the tool reported it. */
19 mime: string
20 at: number
21 note?: string
22}
23
24export type BrowserCamAction = {
25 id: string
26 /** The tool's own name, without the `mcp__<server>__` prefix. */
27 tool: string
28 /** Its key input, short: action, coordinate, url, text. */
29 summary: string
30 isOk: boolean
31 at: number
32}
33
34export type BrowserCamPage = { url?: string; title?: string }
35
36declare module 'claude-code' {
37 interface PluginState {
38 'browser-cam': {
39 /** The latest screenshot only; older ones are dropped. */
40 frame: BrowserCamFrame | null
41 /** The last 30, oldest first. */
42 actions: BrowserCamAction[]
43 page: BrowserCamPage
44 /** The pane has opened by itself once this session: never again, reload or not. */
45 hasAutoOpened: boolean
46 /** The last call order handed out: a reload keeps counting from here, so newest-wins holds. */
47 seq: number
48 /** Sessions that have ended (newest last): work queued for one of them writes nothing. */
49 endedSessions: string[]
50 }
51 }
52}
53