Records what Claude does in the browser (pages opened, clicks, typing, keys, form fills) and the states expected along the way as a recipe to replay; /journey…

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 167 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Journey, JourneyStep, JourneyTarget } from '../types'
5import { BROWSER_TOOL, markdownOf, maskedUrl, playwrightOf, refsOf, stepsOf, stepText } from './journey'
6
7const PANE = 'journey-trace'
8const TITLE = '操作の記録'
9const EXPECT_TOOL = 'mcp__journey-trace__expect'
10const KEEP_STEPS = 200
11const KEEP_REFS = 500
12
13const fresh = (name: string, at: number): Journey => ({ name, startedAt: at, isRecording: true, steps: [] })
14
15const journey = atom({ plugin: 'journey-trace', key: 'journey' } as const, fresh('ブラウザ操作の記録', 0))
16const refs = atom({ plugin: 'journey-trace', key: 'refs' } as const, {} as Record<string, { role?: string; name?: string }>)
17const url = atom({ plugin: 'journey-trace', key: 'url' } as const, '')
18
19async function expectStep($: EngineInterface, text: string, by: 'person' | 'claude'): Promise<void> {
20 const at = await $.clock.now()
21 const page = await read($, url)
22 await update($, journey, j => ({ ...j, steps: [...j.steps, { kind: 'expect' as const, text, url: page, at, by }].slice(-KEEP_STEPS) }))
23}
24
25async function exportText($: EngineInterface, format: 'md' | 'e2e'): Promise<string> {
26 const j = await read($, journey)
27 return format === 'md' ? markdownOf(j, await $.clock.now()) : playwrightOf(j)
28}
29
30/**
31 * The element text typed next goes into: the one last clicked or filled by name. Not known after
32 * a Tab or an Enter (the focus moved), a new page, or a click by coordinates.
33 */
34function focusOf(steps: readonly JourneyStep[]): JourneyTarget | undefined {
35 for (let i = steps.length - 1; i >= 0; i -= 1) {
36 const one = steps[i] as JourneyStep
37 if (one.kind === 'goto') return undefined
38 if (one.kind === 'key' && /tab|enter|return/i.test(one.key)) return undefined
39 if (one.kind === 'click' || one.kind === 'fill') return one.target.name === undefined ? undefined : one.target
40 }
41 return undefined
42}
43
44export const register: Register = on => {
45 on('session.start', async ($, e, next) => {
46 await $.command.register({
47 name: 'journey',
48 description: 'ブラウザ操作を再現レシピに: 表示 · start [名前] · stop · resume · expect <期待> · md(バグ報告)· e2e(Playwright)· clear',
49 argumentHint: '[start 名前 | stop | resume | expect 期待 | md | e2e | clear]',
50 })
51 await $.tool.register({
52 name: 'expect',
53 description:
54 'Record the state the page is expected to be in at this point of the browser journey that journey-trace is recording (a line of a bug report\'s expected result, an assertion of the E2E draft). Call it after checking the page, with the visible text or state that should hold, e.g. "カートに 1 件と表示される".',
55 inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'The expected visible state, one short sentence' } }, required: ['text'] },
56 })
57 const started = await next(e)
58 const j = await read($, journey)
59 if (j.startedAt === 0) await update($, journey, () => fresh('ブラウザ操作の記録', 1))
60 return started
61 })
62
63 on('tool.call', { tool: EXPECT_TOOL }, async ($, e) => {
64 const text = String((e as unknown as { text?: unknown }).text ?? '').trim()
65 if (text === '') return { result: { isRecorded: false }, text: 'text が空です。' }
66 await expectStep($, text, 'claude')
67 return { result: { isRecorded: true }, text: `期待状態を記録しました: ${text}` }
68 })
69
70 on('tool.call', async ($, e, next) => {
71 const tool = String(e.tool)
72 if (!BROWSER_TOOL.test(tool)) return next(e)
73 const name = tool.replace(BROWSER_TOOL, '')
74 const input = e as unknown as Record<string, unknown>
75 const ran = await next(e)
76 if (ran.deny !== undefined || ran.isError === true) return ran
77 const text = ran.text ?? ''
78
79 if (name === 'find' || name === 'read_page') {
80 const found = refsOf(text)
81 if (Object.keys(found).length > 0) await update($, refs, all => Object.fromEntries(Object.entries({ ...all, ...found }).slice(-KEEP_REFS)))
82 }
83 if (name === 'navigate') {
84 const shown = text.split('\n').slice(0, 3).map(line => line.match(/^URL: (https?:\/\/\S+)$/)?.[1]).find(Boolean)
85 const to = typeof input.url === 'string' && /^https?:\/\//.test(input.url) ? input.url : undefined
86 if (shown ?? to) await update($, url, () => maskedUrl((shown ?? to) as string))
87 }
88 if (name === 'resize_window' && typeof input.width === 'number' && typeof input.height === 'number') {
89 const viewport = { width: input.width, height: input.height }
90 await update($, journey, j => ({ ...j, viewport }))
91 }
92
93 const j = await read($, journey)
94 if (!j.isRecording) return ran
95 const at = await $.clock.now()
96 const page = await read($, url)
97 const steps = stepsOf(name, input, await read($, refs), page, at, focusOf(j.steps))
98 if (steps.length > 0) await update($, journey, old => ({ ...old, steps: [...old.steps, ...steps].slice(-KEEP_STEPS) }))
99 return ran
100 }).catch(($, e, next) => next(e)) // an observer: a failure here never changes the call
101
102 on('command.run', { command: 'journey' }, async ($, e) => {
103 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
104 const arg = rest.join(' ').trim()
105 switch (verb) {
106 case 'start': {
107 const at = await $.clock.now()
108 const name = arg || `ブラウザ操作の記録 ${new Date(at).toLocaleString()}`
109 await update($, journey, () => fresh(name, at))
110 return { text: `⏺ 記録を始めました: ${name}` }
111 }
112 case 'stop':
113 await update($, journey, j => ({ ...j, isRecording: false }))
114 return { text: '⏸ 記録を止めました(/journey resume で再開)。' }
115 case 'resume':
116 await update($, journey, j => ({ ...j, isRecording: true }))
117 return { text: '⏺ 記録を再開しました。' }
118 case 'clear':
119 await update($, journey, j => ({ ...j, steps: [] }))
120 return { text: '記録を消しました。' }
121 case 'expect':
122 if (arg === '') return { text: '期待する状態を書いてください: /journey expect <文>' }
123 await expectStep($, arg, 'person')
124 return { text: `期待状態を記録しました: ${arg}` }
125 case 'md':
126 case 'e2e': {
127 const text = await exportText($, verb)
128 const copied = await $.ui.copy({ text }).catch(() => undefined)
129 return { text: `${copied?.isCopied ? '(クリップボードにコピーしました)\n' : ''}${verb === 'e2e' ? '```ts\n' : ''}${text}${verb === 'e2e' ? '\n```' : ''}` }
130 }
131 default: {
132 await $.ui.open({ id: PANE, title: TITLE })
133 const j = await read($, journey)
134 return { text: `${j.isRecording ? '⏺' : '⏸'} ${j.name}: ${j.steps.length}ステップ` }
135 }
136 }
137 })
138
139 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
140 const { Box, Button, Text } = $.ui.resolve(e)
141 const j = await read($, journey)
142 const room = Math.max(3, (e.props.scroll.bodyRows ?? 24) - 4)
143 let n = 0
144 const rows = j.steps.map(step => (step.kind === 'expect' ? { text: ` ✓ ${stepText(step)}`, isExpect: true } : { text: `${String(++n).padStart(2)}. ${stepText(step)}`, isExpect: false }))
145 const copy = (format: 'md' | 'e2e', surface: Parameters<EngineInterface['ui']['copy']>[0]['surface']) => async () => {
146 await $.ui.copy({ text: await exportText($, format), ...(surface === undefined ? {} : { surface }) })
147 $.ui.toast(format === 'md' ? '📋 再現手順(Markdown)をコピーしました' : '📋 Playwright の下書きをコピーしました')
148 }
149 return (
150 <Box flexDirection="column">
151 <Text bold>{`${j.isRecording ? '⏺ 記録中' : '⏸ 停止中'} · ${j.name} · ${n}操作`}</Text>
152 {rows.length === 0 && <Text dimColor>まだ操作はありません。Claude がブラウザを操作すると、ここに手順が並びます。</Text>}
153 {rows.slice(-room).map((row, index) => (
154 <Text key={`s-${index}`} wrap="truncate-end" color={row.isExpect ? 'green' : undefined}>
155 {row.text}
156 </Text>
157 ))}
158 <Box>
159 <Button key="md" label="再現手順をコピー" onPress={press => copy('md', press.surface)()} />
160 <Button key="e2e" label="Playwright をコピー" onPress={press => copy('e2e', press.surface)()} />
161 <Button key="fill" label="プロンプトへ" onPress={async () => $.prompt.fill({ text: await exportText($, 'md'), mode: 'insert' })} />
162 </Box>
163 </Box>
164 )
165 })
166}
167hooks/journey.ts 293 lines1// The journey, pure: browser calls read as steps, and steps written out as a bug report or a
2// Playwright test.
3
4import type { Journey, JourneyStep, JourneyTarget } from '../types'
5
6export const BROWSER_TOOL = /^mcp__(?:remote-devices__)?(?:claude-in-chrome|Claude_Browser)__/
7
8const ROLES = 'button|link|textbox|searchbox|checkbox|radio|combobox|listbox|option|menuitem|tab|switch|slider|heading|img|image|cell|row|listitem|dialog|textarea|input|select'
9const REF_LINE = new RegExp(`\\b(ref_\\d+)\\b`)
10
11/** Elements a find or read_page answer describes: each ref with the role and the quoted name on its line. */
12export function refsOf(text: string): Record<string, { role?: string; name?: string }> {
13 const out: Record<string, { role?: string; name?: string }> = {}
14 for (const line of text.split('\n')) {
15 const ref = line.match(REF_LINE)?.[1]
16 if (ref === undefined) continue
17 const role = line.match(new RegExp(`\\b(${ROLES})\\b`, 'i'))?.[1]?.toLowerCase()
18 const name = line.match(/"([^"]{1,80})"/)?.[1] ?? line.match(/'([^']{1,80})'/)?.[1]
19 out[ref] = { ...(role === undefined ? {} : { role }), ...(name === undefined ? {} : { name }) }
20 }
21 return out
22}
23
24// A field that takes a secret, by the name the page gave it: whole words, so a passport, a token
25// count or a secretary is no secret.
26const SECRET_FIELD = new RegExp(
27 [
28 String.raw`\bpass(?:word|code|phrase|wort)?s?\b`, 'mot de passe', 'contraseña', 'kennwort', String.raw`\bpwd\b`,
29 'パスワード', 'パスコード', '暗証', String.raw`\bsecret\b`, String.raw`\b(?:api|access|private|secret)[ _-]?keys?\b`,
30 String.raw`\btokens?\b(?!\s*(?:count|per|usage|limit))`, String.raw`\botp\b`, String.raw`\btotp\b`, String.raw`\bmfa\b`, String.raw`\b2fa\b`,
31 'one[- ]?time', 'ワンタイム', String.raw`\bpin\b(?!\s+to\b)`, String.raw`\bcv[vc]\b`, 'security (?:code|answer)', 'セキュリティコード',
32 String.raw`\b(?:verification|authentication|auth|sms|recovery|backup|confirmation|access|login)\s+codes?\b`, String.raw`\b\d-digit code\b`,
33 '(?:seed|recovery) phrase', 'card number', 'カード番号', '認証コード', '確認コード', '確認番号',
34 ].join('|'),
35 'i',
36)
37// Text shaped like a credential wherever it is typed.
38const SHAPES = String.raw`(?:sk-(?:ant-)?[\w-]{16,}|gh[pousr]_[A-Za-z0-9]{10,}|github_pat_\w{10,}|xox[abprs]-[\w-]{8,}|AKIA[0-9A-Z]{16}|glpat-[\w-]{20,}|npm_[A-Za-z0-9]{36}|(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}|AIza[\w-]{35}|eyJ[\w-]{8,}\.eyJ[\w-]{8,}\.[\w-]*)`
39const SECRET_SHAPE = new RegExp(String.raw`(?<![\w-])${SHAPES}`)
40const SECRET_SHAPES = new RegExp(String.raw`(?<![\w-])${SHAPES}`, 'g')
41// Query and fragment parameters that carry a secret. `code` only on a page that receives one
42// (an OAuth callback); `state` and `key` are ordinary words there.
43const SECRET_PARAM = /^(?:.*(?:token|secret|password|passwd|signature|api[_-]?key|otp|jwt)|.*session(?:[_-]?id)?|jsessionid|sid|pwd|pass|auth|authorization|sig|auth[_-]?code|code[_-]?verifier)$/i
44const CALLBACK = /callback|oauth|\/auth\b|login|signin|sign-in|redirect/i
45
46export const SECRET = '***'
47
48function maskedParams(params: string, isCallback: boolean): string {
49 return params
50 .split('&')
51 .map(pair => {
52 const at = pair.indexOf('=')
53 if (at <= 0) return pair
54 const name = decodeURIComponentSafe(pair.slice(0, at))
55 return SECRET_PARAM.test(name) || (isCallback && /^code$/i.test(name)) ? `${pair.slice(0, at)}=${SECRET}` : pair
56 })
57 .join('&')
58}
59
60/** A URL as kept: its user part, secret parameters (query and fragment), and key-shaped text in its path masked. */
61export function maskedUrl(url: string): string {
62 const hashAt = url.indexOf('#')
63 const beforeHash = hashAt === -1 ? url : url.slice(0, hashAt)
64 const hash = hashAt === -1 ? undefined : url.slice(hashAt + 1)
65 const [head = '', ...rest] = beforeHash.split('?')
66 const isCallback = CALLBACK.test(head)
67 const base = head
68 .replace(/^(\w[\w+.-]*:\/\/)([^/@\s:]+):[^/\s]+@/, `$1$2:${SECRET}@`)
69 .replace(/^(\w[\w+.-]*:\/\/)([^/@\s:]+)@/, (whole, scheme: string, user: string) => (SECRET_SHAPE.test(user) || user.length > 24 ? `${scheme}${SECRET}@` : whole))
70 .replace(SECRET_SHAPES, SECRET)
71 const query = rest.length === 0 ? '' : `?${maskedParams(rest.join('?'), isCallback)}`
72 // A fragment carries parameters too: an implicit OAuth flow's `#access_token=…`, a hash route's `#/x?token=…`.
73 let fragment = ''
74 if (hash !== undefined) {
75 const inner = hash.indexOf('?')
76 fragment = `#${inner === -1 ? (hash.includes('=') ? maskedParams(hash, true) : hash) : `${hash.slice(0, inner)}?${maskedParams(hash.slice(inner + 1), isCallback)}`}`
77 }
78 return `${base}${query}${fragment}`
79}
80
81function decodeURIComponentSafe(text: string): string {
82 try {
83 return decodeURIComponent(text)
84 } catch {
85 return text
86 }
87}
88
89// Roles a secret is typed into: a checkbox named "Remember password" holds none.
90const TEXT_ROLES = new Set(['textbox', 'input', 'textarea', 'searchbox', 'combobox'])
91
92/**
93 * Text typed where the field is not known (after Tab or Enter, a click by coordinates, a new
94 * page) that reads as a password or a one-time code: no spaces, not an address, and either
95 * three kinds of character or four to eight digits.
96 */
97export function isPasswordLike(text: string): boolean {
98 if (/\s/.test(text) || /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(text) || /^\w+:\/\//.test(text)) return false
99 if (/^\d{4,8}$/.test(text)) return true
100 const kinds = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter(kind => kind.test(text)).length
101 return text.length >= 6 && text.length <= 128 && kinds >= 3
102}
103
104const isSecretTarget = (t: JourneyTarget | undefined) =>
105 t?.name !== undefined && (t.role === undefined || TEXT_ROLES.has(t.role)) && SECRET_FIELD.test(t.name)
106
107/**
108 * The steps one browser call makes, read from its input. A value filled into a field named as a
109 * secret, text typed while one has the focus (`focus`: the element last clicked or filled, absent
110 * when it is not known: after Tab or Enter, a click by coordinates, a new page) or, focus unknown,
111 * text that reads as a password, and text shaped like a credential are kept as `***`: the journey
112 * is copied out as a report or a test.
113 */
114export function stepsOf(
115 tool: string,
116 input: Record<string, unknown>,
117 refs: Readonly<Record<string, { role?: string; name?: string }>>,
118 url: string,
119 at: number,
120 focus?: JourneyTarget,
121): JourneyStep[] {
122 url = maskedUrl(url)
123 const str = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : undefined)
124 const target = (): JourneyTarget => {
125 const ref = str('ref')
126 const coordinate = Array.isArray(input.coordinate) ? (input.coordinate as unknown[]) : undefined
127 const known = ref === undefined ? undefined : refs[ref]
128 return {
129 ...(ref === undefined ? {} : { ref }),
130 ...(known ?? {}),
131 ...(coordinate !== undefined && typeof coordinate[0] === 'number' && typeof coordinate[1] === 'number' ? { x: coordinate[0], y: coordinate[1] } : {}),
132 }
133 }
134 if (tool === 'navigate') {
135 const to = str('url')
136 if (to === undefined) return []
137 if (to === 'back' || to === 'forward') return [{ kind: 'key', key: to === 'back' ? 'Alt+ArrowLeft' : 'Alt+ArrowRight', url, at }]
138 // Typed without a scheme, the address the browser landed on says which (`url`, from the
139 // navigation's answer); failing that, a local host is http and anything else https.
140 const isLocal = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|[\w.-]+\.(?:localhost|local|test))(?::\d+)?(?:\/|$)/.test(to)
141 const landed = /^https?:\/\//.test(url) && url.replace(/^https?:\/\//, '').startsWith(to.replace(/\/+$/, '')) ? url : undefined
142 return [{ kind: 'goto', url: maskedUrl(/^https?:\/\//.test(to) ? to : (landed ?? `${isLocal ? 'http' : 'https'}://${to}`)), at }]
143 }
144 if (tool === 'form_input') {
145 const value = String(input.value ?? '')
146 const where = target()
147 return isSecretTarget(where) || SECRET_SHAPE.test(value)
148 ? [{ kind: 'fill', target: where, value: SECRET, url, at, isSecret: true }]
149 : [{ kind: 'fill', target: where, value, url, at }]
150 }
151 if (tool !== 'computer') return []
152 switch (str('action')) {
153 case 'left_click':
154 return [{ kind: 'click', target: target(), button: 'left', url, at }]
155 case 'right_click':
156 return [{ kind: 'click', target: target(), button: 'right', url, at }]
157 case 'double_click':
158 case 'triple_click':
159 return [{ kind: 'click', target: target(), button: 'double', url, at }]
160 case 'type':
161 if (str('text') === undefined) return []
162 const isKnown = focus?.name !== undefined
163 return isSecretTarget(focus) || SECRET_SHAPE.test(str('text') as string) || (!isKnown && isPasswordLike(str('text') as string))
164 ? [{ kind: 'type', text: SECRET, url, at, isSecret: true }]
165 : [{ kind: 'type', text: str('text') as string, url, at }]
166 case 'key':
167 return str('text') === undefined ? [] : [{ kind: 'key', key: str('text') as string, url, at }]
168 case 'scroll':
169 return [{ kind: 'scroll', direction: str('scroll_direction') ?? 'down', url, at }]
170 default:
171 return []
172 }
173}
174
175const describeTarget = (t: JourneyTarget): string => {
176 const roleJa: Record<string, string> = { button: 'ボタン', link: 'リンク', textbox: '入力欄', searchbox: '検索欄', checkbox: 'チェックボックス', radio: 'ラジオボタン', combobox: '選択欄', tab: 'タブ', menuitem: 'メニュー項目', option: '選択肢' }
177 if (t.name !== undefined) return `「${t.name}」${t.role === undefined ? '' : roleJa[t.role] ?? t.role}`
178 if (t.ref !== undefined) return `要素 ${t.ref}`
179 if (t.x !== undefined) return `画面座標 (${t.x}, ${t.y})`
180 return '要素'
181}
182
183const KEY_JA: Record<string, string> = { Return: 'Enter', Enter: 'Enter', Tab: 'Tab', Escape: 'Esc' }
184
185export function stepText(step: JourneyStep): string {
186 switch (step.kind) {
187 case 'goto':
188 return `${step.url} を開く`
189 case 'click':
190 return `${describeTarget(step.target)}を${step.button === 'right' ? '右クリック' : step.button === 'double' ? 'ダブルクリック' : 'クリック'}`
191 case 'type':
192 return step.isSecret === true ? '秘密の値を入力(記録していません)' : `「${step.text}」と入力`
193 case 'key':
194 return `${KEY_JA[step.key] ?? step.key} キーを押す`
195 case 'fill':
196 return step.isSecret === true ? `${describeTarget(step.target)}に秘密の値を入力(記録していません)` : `${describeTarget(step.target)}に「${step.value}」を入力`
197 case 'scroll':
198 return `${step.direction === 'up' ? '上' : step.direction === 'left' ? '左' : step.direction === 'right' ? '右' : '下'}へスクロール`
199 case 'expect':
200 return `期待: ${step.text}`
201 }
202}
203
204/** The journey as a bug report's body. */
205export function markdownOf(j: Journey, now: number): string {
206 const actions = j.steps.filter(step => step.kind !== 'expect')
207 const expects = j.steps.filter((step): step is Extract<JourneyStep, { kind: 'expect' }> => step.kind === 'expect')
208 const first = j.steps.find((step): step is Extract<JourneyStep, { kind: 'goto' }> => step.kind === 'goto')
209 return [
210 `## ${j.name}`,
211 '',
212 '### 再現手順',
213 ...(actions.length === 0 ? ['(記録された操作はありません)'] : actions.map((step, i) => `${i + 1}. ${stepText(step)}`)),
214 '',
215 '### 期待される結果',
216 ...(expects.length === 0 ? ['- (記入してください)'] : expects.map(step => `- ${step.text}`)),
217 '',
218 '### 実際の結果',
219 '- (記入してください)',
220 '',
221 '### 環境',
222 `- URL: ${first?.url ?? '—'}`,
223 ...(j.viewport === undefined ? [] : [`- 画面サイズ: ${j.viewport.width}×${j.viewport.height}`]),
224 `- 記録日時: ${new Date(now).toLocaleString()}`,
225 ].join('\n')
226}
227
228const q = (text: string) =>
229 `'${text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')}'`
230const PW_ROLE: Record<string, string> = { textbox: 'textbox', input: 'textbox', textarea: 'textbox', searchbox: 'searchbox', button: 'button', link: 'link', checkbox: 'checkbox', radio: 'radio', combobox: 'combobox', select: 'combobox', tab: 'tab', menuitem: 'menuitem', option: 'option', heading: 'heading', img: 'img', image: 'img', switch: 'switch', slider: 'slider' }
231
232function locator(t: JourneyTarget): string | undefined {
233 if (t.name !== undefined && t.role !== undefined && PW_ROLE[t.role] !== undefined) return `page.getByRole(${q(PW_ROLE[t.role] as string)}, { name: ${q(t.name)} })`
234 if (t.name !== undefined) return `page.getByText(${q(t.name)})`
235 return undefined
236}
237
238const PW_KEY: Record<string, string> = { Return: 'Enter', enter: 'Enter', Escape: 'Escape', esc: 'Escape', space: 'Space', tab: 'Tab', cmd: 'Meta', ctrl: 'Control', alt: 'Alt', shift: 'Shift', super: 'Meta' }
239const pwKey = (key: string) => key.split('+').map(part => PW_KEY[part] ?? PW_KEY[part.toLowerCase()] ?? (part.length === 1 ? part : part[0]?.toUpperCase() + part.slice(1))).join('+')
240
241/** The journey as a Playwright test: what it could not pin to an element is a TODO. */
242export function playwrightOf(j: Journey): string {
243 const lines: string[] = []
244 // A secret was never kept: the test reads each from the environment.
245 let secrets = 0
246 const secret = () => `process.env.JOURNEY_SECRET_${(secrets += 1)} ?? ''`
247 if (j.viewport !== undefined) lines.push(` await page.setViewportSize({ width: ${j.viewport.width}, height: ${j.viewport.height} })`)
248 for (const step of j.steps) {
249 switch (step.kind) {
250 case 'goto':
251 if (step.url.includes(SECRET)) lines.push(` // TODO: 伏せた値(${SECRET})を実際の値に置き換える`)
252 lines.push(` await page.goto(${q(step.url)})`)
253 break
254 case 'click': {
255 const where = locator(step.target)
256 const options = step.button === 'right' ? "{ button: 'right' }" : ''
257 if (where !== undefined) lines.push(` await ${where}.${step.button === 'double' ? 'dblclick' : 'click'}(${options})`)
258 else if (step.target.x !== undefined) {
259 lines.push(` // TODO: ${step.target.ref ?? '座標'} を要素のロケーターに置き換える`)
260 lines.push(` await page.mouse.${step.button === 'double' ? 'dblclick' : 'click'}(${step.target.x}, ${step.target.y}${options === '' ? '' : `, ${options}`})`)
261 } else lines.push(` // TODO: クリック対象 ${step.target.ref ?? ''} のロケーターを書く`)
262 break
263 }
264 case 'type':
265 lines.push(step.isSecret === true ? ` await page.keyboard.type(${secret()}) // 秘密の値: 環境変数で渡す` : ` await page.keyboard.type(${q(step.text)})`)
266 break
267 case 'key':
268 lines.push(` await page.keyboard.press(${q(pwKey(step.key))})`)
269 break
270 case 'fill': {
271 const where = locator(step.target)
272 const value = step.isSecret === true ? secret() : q(step.value)
273 const role = step.target.role ?? ''
274 // A select takes an option, a checkbox a check: Playwright refuses fill() on either.
275 const action = ['combobox', 'select', 'listbox'].includes(role)
276 ? `selectOption(${value})`
277 : ['checkbox', 'radio', 'switch'].includes(role)
278 ? /^(?:true|on|checked|1|yes)$/i.test(step.value) ? 'check()' : 'uncheck()'
279 : `fill(${value})`
280 lines.push(where === undefined ? ` // TODO: ${step.target.ref ?? '入力欄'} に ${value} を入れる` : ` await ${where}.${action}${step.isSecret === true ? ' // 秘密の値: 環境変数で渡す' : ''}`)
281 break
282 }
283 case 'scroll':
284 lines.push(` await page.mouse.wheel(0, ${step.direction === 'up' ? -600 : 600})`)
285 break
286 case 'expect':
287 lines.push(` await expect(page.getByText(${q(step.text)})).toBeVisible() // 期待: 文言は画面に合わせて調整`)
288 break
289 }
290 }
291 return [`import { test, expect } from '@playwright/test'`, '', `test(${q(j.name)}, async ({ page }) => {`, ...lines, '})', ''].join('\n')
292}
293types/index.d.ts 33 lines1/** What a step points at: an element as the page described it, or a place on the screen. */
2export type JourneyTarget = { ref?: string; role?: string; name?: string; x?: number; y?: number }
3
4/** One step of a journey. A secret typed or filled (`isSecret`) is never kept: its text is `***`. */
5export type JourneyStep =
6 | { kind: 'goto'; url: string; at: number }
7 | { kind: 'click'; target: JourneyTarget; button: 'left' | 'right' | 'double'; url: string; at: number }
8 | { kind: 'type'; text: string; target?: JourneyTarget; url: string; at: number; isSecret?: true }
9 | { kind: 'key'; key: string; url: string; at: number }
10 | { kind: 'fill'; target: JourneyTarget; value: string; url: string; at: number; isSecret?: true }
11 | { kind: 'scroll'; direction: string; url: string; at: number }
12 | { kind: 'expect'; text: string; url: string; at: number; by: 'person' | 'claude' }
13
14export type Journey = {
15 name: string
16 startedAt: number
17 isRecording: boolean
18 steps: JourneyStep[]
19 /** The size of the page's window, when a screenshot or a resize said it. */
20 viewport?: { width: number; height: number }
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'journey-trace': {
26 journey: Journey
27 /** Elements the page described (`ref_12` → button "Save"), from find and read_page. */
28 refs: Record<string, { role?: string; name?: string }>
29 url: string
30 }
31 }
32}
33