危ない操作は止めて確認し、Claude が書いたファイルは自動で構文チェックする。入力欄の上に、結果と、ほかの作業中のチャットを表示

kokot が作った Claude Code 用の mod 集です。
| mod | できること |
|---|---|
| usage-band | 入力欄の上に、残りの使用量(5時間枠・週枠・コンテキスト)とリセットまでの時間を帯で表示する。7日間のトークンを何に使ったかの円グラフも出す。チャット名の色分けもする |
| chat-time | LINE のように、各メッセージの下に送った日時を表示する |
| ja-questions | Claude からの質問が英語なら、日本語に訳して表示する |
| push-to-talk | 決めたキーを押している間だけ録音して、話した内容を入力欄に入れる |
| shield | 危ない操作(フォルダごと削除・履歴の上書き・秘密ファイルの書き換えなど)は止めて確認し、Claude が書いたファイルは自動で構文チェックする。ほかの作業中のチャットも表示し、名前を押すとそのチャットへ飛べる。守るもの・守れないものは shield/SAFETY.md |
ログインはいりません。ほしい mod だけ選んで入れられます。
claude plugin marketplace add kokotosu/claude-mods
claude plugin install usage-band@kokot-claude-mods
claude plugin install chat-time@kokot-claude-mods
claude plugin install ja-questions@kokot-claude-mods
claude plugin install push-to-talk@kokot-claude-mods
claude plugin install shield@kokot-claude-mods
Claude に頼むなら「GitHub の kokotosu/claude-mods から chat-time だけ入れて」のように言えば入れてくれる。
claude plugin marketplace update kokot-claude-mods
そのあと claude plugin update <mod名>@kokot-claude-mods を打つ。
~/.claude/mods/chat-colors.json もコピーするhooks/register.tsx 225 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { ShieldEvent, ShieldTally } from '../types'
4import { check, isCheckable } from './check'
5import { otherChats } from './chats'
6import { CHATS_HEIGHT, FRESH_MS, HEIGHT, chatButtons, describe, describeChats, kindSvg, nameOf, svg } from './draw'
7import { dangerOf, isSecret } from './rules'
8
9const last = { plugin: 'shield', key: 'last' } as const
10const tally = { plugin: 'shield', key: 'tally' } as const
11const others = { plugin: 'shield', key: 'others' } as const
12// How often this chat looks at the shared scan of the others.
13const CHATS_EVERY_MS = 5_000
14const NONE: ShieldTally = { stopped: 0, allowed: 0, checked: 0, broken: 0 }
15
16// The slot's width is known only in cells; a cell is about this wide.
17const CELL_PX = 7.8
18
19const RUN = '実行する'
20const STOP = 'やめる'
21
22// Records what happened, counts it, and redraws once it is no longer new.
23async function record($: EngineInterface, event: Omit<ShieldEvent, 'at'>, counted: (keyof ShieldTally)[]) {
24 const at = await $.clock.now()
25 const { value: counts = NONE } = await $.state.get(tally)
26 const next = { ...counts }
27 for (const key of counted) next[key] += 1
28
29 await $.state.set(tally, next)
30 await $.state.set(last, { ...event, at })
31 $.clock.after(FRESH_MS + 100, () => $.ui.invalidate('ui.render'))
32}
33
34// Asks the person before something that cannot be undone. Nobody there to
35// ask, or the question closed, is a no.
36async function permits($: EngineInterface, what: string, subject: string): Promise<boolean> {
37 try {
38 const answer = await $.ui.ask(`危ない操作です(${what})。実行しますか?\n${subject.slice(0, 300)}`, {
39 header: 'SHIELD',
40 options: [RUN, STOP],
41 })
42 return answer === RUN
43 } catch {
44 return false
45 }
46}
47
48const stopped = (what: string) =>
49 `SHIELD: この操作(${what})はユーザーが止めました。実行せずに、別のやり方にするか、どうするかユーザーに聞いてください。`
50
51const fileName = (path: string) => path.split(/[\\/]/).pop() ?? path
52
53// A dangerous command waits for the person's word.
54async function guardCommand($: EngineInterface, command: string): Promise<string | null> {
55 const danger = dangerOf(command)
56 if (danger === null) return null
57
58 const isAllowed = await permits($, danger.what, command)
59 await record(
60 $,
61 { kind: isAllowed ? 'allowed' : 'stopped', subject: command, detail: danger.what },
62 [isAllowed ? 'allowed' : 'stopped'],
63 )
64
65 return isAllowed ? null : stopped(danger.what)
66}
67
68// A file Claude writes: a secret one waits for the person's word, and any
69// one it can read is checked once written. A broken one goes back to Claude.
70async function guardFile($: EngineInterface, path: string): Promise<string | null> {
71 if (!isSecret(path)) return null
72 const what = 'パスワードや鍵が入るファイルを書き換える'
73 const isAllowed = await permits($, what, path)
74 await record($, { kind: isAllowed ? 'allowed' : 'stopped', subject: fileName(path), detail: what }, [
75 isAllowed ? 'allowed' : 'stopped',
76 ])
77
78 return isAllowed ? null : stopped(what)
79}
80
81async function checkFile<R extends { deny?: unknown; isError?: unknown; context?: readonly string[] }>($: EngineInterface, path: string, ran: R): Promise<R> {
82 if (ran.deny !== undefined || ran.isError === true || !isCheckable(path)) return ran
83 const verdict = await check($, path)
84 if (verdict === null) return ran
85
86 if (verdict.isOk) {
87 await record($, { kind: 'ok', subject: fileName(path), detail: '書いたファイルに構文エラーはありません' }, ['checked'])
88 return ran
89 }
90
91 const where = verdict.line !== undefined ? `${fileName(path)}:${verdict.line}` : fileName(path)
92 await record($, { kind: 'broken', subject: where, detail: verdict.message }, ['checked', 'broken'])
93
94 return {
95 ...ran,
96 context: [
97 ...(ran.context ?? []),
98 `SHIELD: いま書いた ${path} に構文エラーがあります${verdict.line !== undefined ? `(${verdict.line}行目あたり)` : ''}: ${verdict.message}\n先にこれを直してください。`,
99 ],
100 }
101}
102
103// Opens another chat in the app, as its sidebar row does: the app answers
104// its own claude:// link, which the system is handed to open.
105async function openChat($: EngineInterface, id: string) {
106 if (!/^local_[0-9a-f-]+$/i.test(id)) return
107 const link = `claude://claude.ai/epitaxy/${id}`
108 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
109 try {
110 const { exitCode } = await $.process.run(
111 isWindows ? ['rundll32.exe', 'url.dll,FileProtocolHandler', link] : ['open', link],
112 { timeoutMs: 10_000 },
113 )
114 if (exitCode !== 0) $.ui.toast('チャットを開けませんでした')
115 } catch {
116 $.ui.toast('チャットを開けませんでした')
117 }
118}
119
120// Takes the latest states of the other chats, redrawing only on a change.
121async function watchChats($: EngineInterface) {
122 try {
123 const found = await otherChats($)
124 const { value: shown = [] } = await $.state.get(others)
125 const key = (list: readonly { id?: string; title: string; state: string }[]) =>
126 list.map(chat => `${chat.state}:${chat.id ?? ''}:${chat.title}`).join('|')
127 if (key(found) !== key(shown)) await $.state.set(others, found)
128 } catch {
129 // The records cannot be read here: the strip stays as it was.
130 }
131}
132
133export const register: Register = on => {
134 on('session.start', async ($, e, next) => {
135 await $.state.set(last, null)
136 await $.state.set(tally, NONE)
137 await $.state.set(others, [])
138
139 $.clock.after(0, () => void watchChats($))
140 $.clock.every(CHATS_EVERY_MS, () => watchChats($))
141
142 return next(e)
143 })
144
145 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
146 const refused = await guardCommand($, e.command)
147 return refused === null ? next(e) : { deny: refused }
148 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'SHIELD: 見張りが動かなかったので止めました。' }))
149
150 on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
151 const refused = await guardCommand($, e.command)
152 return refused === null ? next(e) : { deny: refused }
153 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'SHIELD: 見張りが動かなかったので止めました。' }))
154
155 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
156 const refused = await guardFile($, e.file_path)
157 if (refused !== null) return { deny: refused }
158
159 return checkFile($, e.file_path, await next(e))
160 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'SHIELD: 見張りが動かなかったので止めました。' }))
161
162 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
163 const refused = await guardFile($, e.file_path)
164 if (refused !== null) return { deny: refused }
165
166 return checkFile($, e.file_path, await next(e))
167 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'SHIELD: 見張りが動かなかったので止めました。' }))
168
169 // The strip sits on top of whatever else is drawn above the prompt.
170 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
171 if (e.props.hasSurvey) return next(e)
172
173 const { value: event = null } = await $.state.get(last)
174 const { value: counts = NONE } = await $.state.get(tally)
175 const { value: chats = [] } = await $.state.get(others)
176 const below = await next(e)
177
178 if (e.surface === 'desktop') {
179 const { Box, Button, Svg, Text } = $.ui.resolve(e)
180 const width = Math.max(240, Math.round(e.props.bodyColumns * CELL_PX))
181 // The other chats go first: this strip always ends on top. Each name
182 // is a button that opens its chat.
183 const groups = chatButtons(chats, width)
184 const row = groups.length === 0 ? null : (
185 <Box flexDirection="row" alignItems="center" gap="sm">
186 {groups.map(({ kind, shown, rest }) => {
187 const label = kindSvg(kind, e.props.isWorking)
188 return (
189 <Box key={kind} flexDirection="row" alignItems="center" gap="xs">
190 <Svg source={label.svg} alt={describeChats(chats.filter(chat => chat.state === kind))} width={label.width} height={CHATS_HEIGHT} />
191 {shown.map(chat => (
192 <Button key={chat.id!} label={nameOf(chat)} onPress={() => void openChat($, chat.id!)} />
193 ))}
194 {rest > 0 && <Text dimColor>+{rest}</Text>}
195 </Box>
196 )
197 })}
198 </Box>
199 )
200
201 return (
202 <Box flexDirection="column">
203 {row}
204 <Svg source={svg(event, counts, width, await $.clock.now(), e.props.isWorking)} alt={describe(event, counts)} width={width} height={HEIGHT} />
205 {below}
206 </Box>
207 )
208 }
209
210 if (e.surface === 'terminal') {
211 const { Box, Text } = $.ui.resolve(e)
212
213 return (
214 <Box flexDirection="column">
215 {chats.length > 0 && <Text dimColor>{describeChats(chats)}</Text>}
216 <Text dimColor>{describe(event, counts)}</Text>
217 {below}
218 </Box>
219 )
220 }
221
222 return below
223 })
224}
225hooks/check.ts 152 lines1// Checks a file Claude just wrote for mistakes that break it outright: a
2// syntax error in JSON, JavaScript, Python, PowerShell, a shell script or CSS.
3// Only reads; it never changes the file.
4
5import type { EngineInterface } from 'claude-code'
6
7export type Verdict = { isOk: true } | { isOk: false; line?: number; message: string }
8
9const extension = (path: string) => /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase() ?? ''
10
11// The kinds of file this can check, by extension.
12const CHECKABLE = new Set(['json', 'js', 'mjs', 'cjs', 'py', 'ps1', 'sh', 'css'])
13
14export const isCheckable = (path: string) => CHECKABLE.has(extension(path))
15
16// The line a byte offset falls on.
17const lineAt = (text: string, offset: number) => text.slice(0, offset).split('\n').length
18
19// The first line of a checker's complaint that says what is wrong, and the
20// line number in it, if any.
21function complaint(output: string, path: string): Verdict {
22 const lines = output.split(/\r?\n/).map(line => line.trim()).filter(line => line !== '')
23 const said = lines.find(line => /error/i.test(line)) ?? lines[lines.length - 1] ?? '構文エラー'
24 const at = new RegExp(`${path.split(/[\\/]/).pop()?.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']?[:,]\\s*(?:line\\s*)?(\\d+)`, 'i').exec(output)
25 const line = Number(at?.[1] ?? /line (\d+)/i.exec(output)?.[1] ?? NaN)
26
27 return { isOk: false, message: said.slice(0, 160), ...(Number.isFinite(line) ? { line } : {}) }
28}
29
30// Reads JSON by its grammar, to say where it breaks: the runtime's own parser
31// does not always tell the position.
32export function json(source: string): Verdict {
33 const text = source.replace(/^/, '')
34 let i = 0
35 const fail = (message: string): Verdict => ({ isOk: false, line: lineAt(text, i), message })
36 const space = () => {
37 while (i < text.length && ' \t\r\n'.includes(text[i]!)) i += 1
38 }
39 const literal = (word: string) => text.startsWith(word, i) && ((i += word.length), true)
40 const string = (): boolean => {
41 if (text[i] !== '"') return false
42 i += 1
43 while (i < text.length && text[i] !== '"') {
44 if (text[i] === '\n') return false
45 i += text[i] === '\\' ? 2 : 1
46 }
47 i += 1
48 return i <= text.length
49 }
50 const value = (): Verdict | null => {
51 space()
52 const c = text[i]
53 if (c === '{' || c === '[') {
54 const close = c === '{' ? '}' : ']'
55 i += 1
56 space()
57 if (text[i] === close) return (i += 1), null
58 for (;;) {
59 if (c === '{') {
60 space()
61 if (!string()) return fail('キーは "..." で囲む必要があります')
62 space()
63 if (text[i] !== ':') return fail(': がありません')
64 i += 1
65 }
66 const inner = value()
67 if (inner !== null) return inner
68 space()
69 if (text[i] === ',') {
70 i += 1
71 space()
72 if (text[i] === close) return fail('最後の , が余分です')
73 continue
74 }
75 if (text[i] === close) return (i += 1), null
76 return fail(`, か ${close} がありません`)
77 }
78 }
79 if (c === '"') return string() ? null : fail('文字列が閉じていません')
80 if (/[-0-9]/.test(c ?? '')) {
81 const number = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/.exec(text.slice(i))
82 return number === null ? fail('数字の書き方が違います') : ((i += number[0].length), null)
83 }
84 if (literal('true') || literal('false') || literal('null')) return null
85 return fail(i >= text.length ? 'ファイルが途中で終わっています' : `ここに値がありません: ${c}`)
86 }
87
88 const broken = value()
89 if (broken !== null) return broken
90 space()
91 return i < text.length ? fail('値のあとに余分な文字があります') : { isOk: true }
92}
93
94// Braces must pair up outside comments and strings.
95function css(text: string): Verdict {
96 const bare = text.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' ')).replace(/(["'])(?:\\.|(?!\1)[^\\\n])*\1/g, m => ' '.repeat(m.length))
97 let depth = 0
98 for (let i = 0; i < bare.length; i++) {
99 if (bare[i] === '{') depth += 1
100 if (bare[i] === '}') depth -= 1
101 if (depth < 0) return { isOk: false, line: lineAt(bare, i), message: '閉じ括弧 } が多すぎます' }
102 }
103 return depth === 0 ? { isOk: true } : { isOk: false, message: `閉じ括弧 } が ${depth} 個足りません` }
104}
105
106async function run($: EngineInterface, argv: string[], path: string): Promise<Verdict | null> {
107 const { exitCode, stdout, stderr } = await $.process.run(argv, { timeoutMs: 20_000 })
108
109 if (exitCode === 0) return { isOk: true }
110 // Only a syntax error counts: a checker that could not run says nothing.
111 const output = `${stderr}\n${stdout}`
112 return /syntax ?error|error line \d/i.test(output) ? complaint(output, path) : null
113}
114
115// The verdict on the file, or null where it cannot be checked here (no
116// checker installed, a file too large): saying nothing beats a false alarm.
117export async function check($: EngineInterface, path: string): Promise<Verdict | null> {
118 try {
119 switch (extension(path)) {
120 case 'json':
121 return json(String(await $.fs.read(path)))
122 case 'css':
123 return css(String(await $.fs.read(path)))
124 case 'js':
125 case 'mjs':
126 case 'cjs':
127 return await run($, ['node', '--check', path], path)
128 case 'py':
129 return await run($, ['python', '-c', 'import ast,sys; ast.parse(open(sys.argv[1], encoding="utf-8").read(), sys.argv[1])', path], path)
130 case 'ps1':
131 return await run(
132 $,
133 [
134 'powershell',
135 '-NoProfile',
136 '-Command',
137 // The path goes in quoted, as -Command passes no arguments on.
138 `[Console]::OutputEncoding=[Text.Encoding]::UTF8; $e=$null; [void][System.Management.Automation.Language.Parser]::ParseFile('${path.replace(/'/g, "''")}',[ref]$null,[ref]$e); ` +
139 'if ($e) { $e | ForEach-Object { "error line $($_.Extent.StartLineNumber): $($_.Message)" }; exit 1 }',
140 ],
141 path,
142 )
143 case 'sh':
144 return await run($, ['bash', '-n', path], path)
145 default:
146 return null
147 }
148 } catch {
149 return null
150 }
151}
152hooks/chats.ts 120 lines1// Which other chats are working and which have an answer you have not read,
2// from the desktop app's own records of its sessions on disk. Nothing is
3// asked of the app: one chat reads the records for all, every few seconds at
4// most, and leaves what it found in the store for the others.
5
6import type { EngineInterface } from 'claude-code'
7
8import type { ChatState } from '../types'
9
10const SCAN_KEY = 'chatScan'
11// How old a scan may be before some chat makes a new one.
12export const SCAN_EVERY_MS = 10_000
13// Only records written this recently are read.
14const RECENT_MS = 24 * 3_600_000
15// A turn with no activity for this long was cut off (the app closed mid-turn).
16const STALE_MS = 15 * 60_000
17
18type Scan = { at: number; chats: (ChatState & { cli: string })[] }
19
20const isScan = (value: unknown): value is Scan =>
21 typeof value === 'object' && value !== null && Array.isArray((value as Scan).chats)
22
23type Record = {
24 cliSessionId?: unknown
25 title?: unknown
26 isArchived?: unknown
27 lastActivityAt?: unknown
28 lastTurnEdgeAt?: unknown
29 latestUserFrameAt?: unknown
30 lastFocusedAt?: unknown
31 completedTurns?: unknown
32 interruptedAwaitingPerson?: unknown
33}
34
35const time = (value: unknown) => (typeof value === 'number' ? value : 0)
36
37// What a record says of its chat: working, waiting on you, or neither.
38export function stateOf(record: Record, now: number): ChatState['state'] | null {
39 if (record.isArchived === true) return null
40 const activity = time(record.lastActivityAt)
41 const edge = time(record.lastTurnEdgeAt)
42 const sent = time(record.latestUserFrameAt)
43 const seen = time(record.lastFocusedAt)
44
45 // A turn begins at your message and its edge marks it; its end moves the
46 // edge on past the message.
47 const isTurnOpen = sent > 0 && sent >= edge - 2_000 && activity >= edge - 2_000
48 if (isTurnOpen && now - activity < STALE_MS) return 'working'
49 if (record.interruptedAwaitingPerson !== null && record.interruptedAwaitingPerson !== undefined && record.interruptedAwaitingPerson !== false) return 'waiting'
50 if (time(record.completedTurns) > 0 && activity > seen + 3_000 && now - activity < RECENT_MS) return 'waiting'
51 return null
52}
53
54async function roots($: EngineInterface): Promise<string[]> {
55 const found: string[] = []
56 const appData = await $.env.get('APPDATA')
57 const localAppData = await $.env.get('LOCALAPPDATA')
58 if (appData !== undefined) found.push(`${appData}/Claude/claude-code-sessions`)
59 if (localAppData !== undefined) {
60 try {
61 for (const entry of await $.fs.list(`${localAppData}/Claude-Data`)) {
62 if (entry.kind === 'dir') found.push(`${localAppData}/Claude-Data/${entry.name}/claude-code-sessions`)
63 }
64 } catch {
65 // The app keeps its records under APPDATA only.
66 }
67 }
68 return found
69}
70
71async function scan($: EngineInterface, now: number): Promise<Scan> {
72 const chats: Scan['chats'] = []
73 for (const root of await roots($)) {
74 try {
75 for (const account of await $.fs.list(root)) {
76 if (account.kind !== 'dir') continue
77 for (const space of await $.fs.list(`${root}/${account.name}`)) {
78 if (space.kind !== 'dir') continue
79 const folder = `${root}/${account.name}/${space.name}`
80 for (const entry of await $.fs.list(folder)) {
81 if (entry.kind !== 'file' || !entry.name.startsWith('local_') || now - entry.mtimeMs > RECENT_MS) continue
82 try {
83 const record = JSON.parse(String(await $.fs.read(`${folder}/${entry.name}`))) as Record
84 const state = stateOf(record, now)
85 if (state === null || typeof record.cliSessionId !== 'string') continue
86 const title = typeof record.title === 'string' && record.title.trim() !== '' ? record.title.trim() : '名前なし'
87 const id = entry.name.replace(/.json$/, '')
88 chats.push({ cli: record.cliSessionId, id, title, state, at: time(record.lastActivityAt) })
89 } catch {
90 // A record being rewritten: it is read next time.
91 }
92 }
93 }
94 }
95 } catch {
96 // A root that is not there.
97 }
98 }
99 return { at: now, chats: chats.sort((a, b) => b.at - a.at) }
100}
101
102// The other chats' states: the store's scan while fresh, else a new one.
103export async function otherChats($: EngineInterface): Promise<ChatState[]> {
104 const now = await $.clock.now()
105 const stored = await $.store.get(SCAN_KEY)
106 let found = isScan(stored) && now - stored.at < SCAN_EVERY_MS ? stored : null
107 if (found === null) {
108 // Claimed before scanning, so chats waking together do not all scan.
109 await $.store.set(SCAN_KEY, { at: now, chats: isScan(stored) ? stored.chats : [] })
110 found = await scan($, now)
111 await $.store.set(SCAN_KEY, found)
112 }
113
114 const self = await $.session.id()
115 // Only the chats at work are shown; the ones waiting on you are left out.
116 return found.chats
117 .filter(chat => chat.cli !== self && chat.state === 'working')
118 .map(({ id, title, state, at }) => ({ ...(id === undefined ? {} : { id }), title, state, at }))
119}
120hooks/draw.ts 209 lines1// The shield's strip above the prompt, in the look of usage-band's chart: a
2// glowing shield that says how the last thing went, what that was, and how
3// many times each has happened this session.
4
5import type { ChatState, ShieldEvent, ShieldTally } from '../types'
6import { breathe, crossing, sheen } from './glow'
7
8export const HEIGHT = 44
9
10const LOOKS: Record<ShieldEvent['kind'], { from: string; to: string; word: string; mark: string }> = {
11 ok: { from: '#8CEBD6', to: '#3DD6B5', word: 'チェックOK', mark: 'check' },
12 broken: { from: '#FFB199', to: '#FF7A59', word: '構文エラー → Claude が修正中', mark: 'bang' },
13 allowed: { from: '#FFD58F', to: '#FFB547', word: '確認して実行', mark: 'check' },
14 stopped: { from: '#FF9A94', to: '#FF5A52', word: '止めました', mark: 'cross' },
15}
16
17const RAIL = '#2E2D2A'
18const LABEL = '#A8A39A'
19const DIM = '#6F6B63'
20const NUMBER = '#FAF9F5'
21const FACE = `'Yu Gothic UI','Meiryo','Hiragino Sans',sans-serif`
22const DIGITS = `'Segoe UI','Helvetica Neue',Arial,sans-serif`
23const MONO = `'Cascadia Code','Consolas','Menlo',monospace`
24
25// How long an event counts as new: its shield pulses meanwhile.
26export const FRESH_MS = 10_000
27
28const escape = (text: string) =>
29 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
30
31// A text cut to about `room` half-width characters; full-width ones count two.
32function fit(text: string, room: number): string {
33 let width = 0
34 let out = ''
35 for (const char of text.replace(/\s+/g, ' ')) {
36 width += /[\u0000-ÿ]/.test(char) ? 1 : 2
37 if (width > room) return `${out}…`
38 out += char
39 }
40 return out
41}
42
43// About how wide a text is drawn at `size`: full-width characters are square,
44// the others a little over half as wide.
45const measure = (text: string, size: number) =>
46 [...text].reduce((width, char) => width + (char.charCodeAt(0) < 0x100 ? size * 0.56 : size), 0)
47
48// The shield, 24 wide and 28 tall, its mark drawn inside it.
49function shield(x: number, y: number, mark: string, fill: string, isFresh: boolean): string {
50 const outline = `M${x + 12} ${y} L${x + 24} ${y + 4.5} V${y + 13} C${x + 24} ${y + 20.5} ${x + 18.8} ${y + 25.6} ${x + 12} ${y + 28} C${x + 5.2} ${y + 25.6} ${x} ${y + 20.5} ${x} ${y + 13} V${y + 4.5} Z`
51 const inner =
52 mark === 'check'
53 ? `<path d="M${x + 7} ${y + 13.5} l3.6 3.6 l6.6 -7" fill="none" stroke="#1F1E1D" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>`
54 : mark === 'cross'
55 ? `<path d="M${x + 8} ${y + 8.5} l8 8 M${x + 16} ${y + 8.5} l-8 8" stroke="#1F1E1D" stroke-width="2.6" stroke-linecap="round"/>`
56 : `<path d="M${x + 12} ${y + 7} v7" stroke="#1F1E1D" stroke-width="2.8" stroke-linecap="round"/><circle cx="${x + 12}" cy="${y + 19}" r="1.7" fill="#1F1E1D"/>`
57 // A new event sends three rings out from the shield.
58 const ripple = isFresh
59 ? `<circle cx="${x + 12}" cy="${y + 14}" r="14" fill="none" stroke="${fill}" stroke-width="1.5" opacity="0">` +
60 `<animate attributeName="r" values="14;24" dur="1.4s" repeatCount="3"/>` +
61 `<animate attributeName="opacity" values="0.8;0" dur="1.4s" repeatCount="3"/></circle>`
62 : ''
63
64 return `${ripple}<path d="${outline}" fill="url(#sh-grad-${mark})" filter="url(#sh-glow)"/>${inner}`
65}
66
67export function describe(last: ShieldEvent | null, tally: ShieldTally): string {
68 const counts = `止めた${tally.stopped} 確認して実行${tally.allowed} チェック${tally.checked} エラー${tally.broken}`
69 return last === null ? `SHIELD ${counts}` : `SHIELD ${LOOKS[last.kind].word}: ${last.subject} / ${counts}`
70}
71
72// The glow drifts while Claude is at work or something has just happened.
73export function svg(last: ShieldEvent | null, tally: ShieldTally, width: number, now: number, isWorking = false): string {
74 const look = LOOKS[last?.kind ?? 'ok']
75 const isFresh = last !== null && now - last.at < FRESH_MS
76 const isMoving = isWorking || isFresh
77
78 // The counts, right-aligned, each a number over its name. A count above
79 // zero glows in its color; a zero glows faintly in grey.
80 const stats: [string, number, ShieldEvent['kind']][] = [
81 ['止めた', tally.stopped, 'stopped'],
82 ['確認', tally.allowed, 'allowed'],
83 ['チェック', tally.checked, 'ok'],
84 ['エラー', tally.broken, 'broken'],
85 ]
86 const STAT = 58
87 const statsLeft = width - 8 - stats.length * STAT
88 let glows = ''
89 let counts = `<line x1="${statsLeft - 14}" y1="10" x2="${statsLeft - 14}" y2="${HEIGHT - 10}" stroke="${RAIL}"/>`
90 stats.forEach(([name, count, kind], i) => {
91 const cx = statsLeft + i * STAT + STAT / 2
92 const { from, to } = LOOKS[kind]
93 const isLit = count > 0
94 glows +=
95 sheen(`sh-num-${i}`, isLit ? from : DIM, isLit ? NUMBER : LABEL, isMoving, i * 0.8) +
96 (isLit ? sheen(`sh-bar-${i}`, to, from, isMoving, i * 0.8) : '')
97 counts +=
98 `<text x="${cx}" y="21" text-anchor="middle" font-family="${DIGITS}" font-size="16" font-weight="700" ` +
99 `fill="url(#sh-num-${i})"${isLit ? ' filter="url(#sh-breathe)"' : ''}>${count}</text>` +
100 `<rect x="${cx - 7}" y="25" width="14" height="2" rx="1" fill="${isLit ? `url(#sh-bar-${i})` : RAIL}"${isLit ? ' filter="url(#sh-breathe)"' : ''}/>` +
101 `<text x="${cx}" y="37" text-anchor="middle" font-family="${FACE}" font-size="9.5" fill="${DIM}">${name}</text>`
102 })
103
104 // The last event: what happened, in its color, then what it was about.
105 const textLeft = 48
106 const room = Math.max(10, Math.floor((statsLeft - 28 - textLeft) / 6.6))
107 const headline =
108 last === null
109 ? `<text x="${textLeft}" y="32" font-family="${FACE}" font-size="12.5" font-weight="700" fill="url(#sh-word)" filter="url(#sh-breathe)">見張り中</text>` +
110 `<text x="${textLeft + measure('見張り中', 12.5)}" y="32" font-family="${FACE}" font-size="12.5" fill="${DIM}"> 危ない操作は止めて確認、書いたファイルは自動でチェック</text>`
111 : `<text x="${textLeft}" y="32" font-family="${FACE}" font-size="12.5" font-weight="700" fill="url(#sh-word)" filter="url(#sh-breathe)">${look.word}</text>` +
112 `<text x="${textLeft + measure(look.word, 12.5) + 12}" y="32" font-family="${MONO}" font-size="11.5" fill="${LABEL}">` +
113 `${escape(fit(last.subject, Math.max(6, room - look.word.length * 2 - 2)))}</text>`
114 const caption =
115 `<text x="${textLeft}" y="15" font-family="${DIGITS}" font-size="9" font-weight="700" letter-spacing="2" fill="${DIM}">SHIELD` +
116 (last !== null ? `<tspan font-family="${FACE}" letter-spacing="0.4" font-weight="400" dx="8">${escape(fit(last.detail, room - 8))}</tspan>` : '') +
117 `</text>`
118
119 const defs =
120 `<defs><linearGradient id="sh-grad-${look.mark}" x1="0" y1="0" x2="0.4" y2="1">` +
121 `<stop offset="0" stop-color="${look.from}"/><stop offset="1" stop-color="${look.to}"/></linearGradient>` +
122 `<filter id="sh-glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="${isFresh ? 3 : 1.8}" result="b"/>` +
123 `<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>` +
124 `${breathe('sh-breathe', isMoving)}${glows}` +
125 `${last === null ? sheen('sh-word', NUMBER, '#8CEBD6', isMoving) : sheen('sh-word', look.to, look.from, isMoving)}` +
126 `${crossing('sh-rail', look.to, RAIL, isMoving)}</defs>`
127
128 return (
129 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${HEIGHT}" width="100%" height="${HEIGHT}" ` +
130 `preserveAspectRatio="xMinYMid meet" style="color-scheme:light dark;background:transparent">` +
131 `<style>:root,html,body{color-scheme:light dark;background:transparent;margin:0}</style>` +
132 `${defs}${shield(12, 8, look.mark, look.to, isFresh)}${caption}${headline}${counts}` +
133 `<rect x="8" y="${HEIGHT - 1.5}" width="${width - 16}" height="1.5" fill="url(#sh-rail)"/></svg>`
134 )
135}
136
137// ---- the other chats, above the shield ----
138
139export const CHATS_HEIGHT = 32
140// A name is cut to about this many half-width characters.
141const NAME_ROOM = 26
142
143const KINDS = {
144 working: { word: '作業中', color: '#3DD6B5', tint: 'rgba(61,214,181,0.14)' },
145 waiting: { word: '回答待ち', color: '#FFB547', tint: 'rgba(255,181,71,0.14)' },
146} as const
147
148export function describeChats(others: readonly ChatState[]): string {
149 return (['working', 'waiting'] as const)
150 .map(kind => {
151 const names = others.filter(chat => chat.state === kind).map(chat => chat.title)
152 return names.length === 0 ? '' : `${KINDS[kind].word}: ${names.join('、')}`
153 })
154 .filter(part => part !== '')
155 .join(' / ')
156}
157
158// ---- the same row on a desktop, each name a button that opens its chat ----
159
160const kindOf = (chats: readonly ChatState[], kind: keyof typeof KINDS) => chats.filter(chat => chat.state === kind)
161
162export const nameOf = (chat: ChatState) => fit(chat.title, NAME_ROOM)
163
164// About how wide a desktop draws a name's button.
165const buttonWidth = (chat: ChatState) => measure(nameOf(chat), 12) + 26
166
167// The kind before its names: a lit dot and its word. The dot's ring spreads
168// only while moving (see glow.ts).
169export function kindSvg(kind: keyof typeof KINDS, isMoving: boolean): { svg: string; width: number } {
170 const look = KINDS[kind]
171 const width = Math.ceil(13 + measure(look.word, 11) + 4)
172 const pulse = isMoving
173 ? `<circle cx="7" cy="16" r="3.5" fill="none" stroke="${look.color}" stroke-width="1.3">` +
174 `<animate attributeName="r" values="3.5;7" dur="1.6s" repeatCount="indefinite"/>` +
175 `<animate attributeName="opacity" values="0.9;0" dur="1.6s" repeatCount="indefinite"/></circle>`
176 : ''
177 return {
178 width,
179 svg:
180 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${CHATS_HEIGHT}" width="${width}" height="${CHATS_HEIGHT}" ` +
181 `style="color-scheme:light dark;background:transparent">` +
182 `<style>:root,html,body{color-scheme:light dark;background:transparent;margin:0}</style>` +
183 `<defs><filter id="ck-glow" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="1.6" result="b"/>` +
184 `<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>` +
185 `${pulse}<circle cx="7" cy="16" r="3.5" fill="${look.color}" filter="url(#ck-glow)"/>` +
186 `<text x="16" y="20" font-family="${FACE}" font-size="11" font-weight="700" fill="${look.color}">${look.word}</text></svg>`,
187 }
188}
189
190// The kinds with chats, each with the names that fit as buttons in `width`
191// and how many more there are. A chat with no id has no button.
192export function chatButtons(chats: readonly ChatState[], width: number): { kind: keyof typeof KINDS; shown: ChatState[]; rest: number }[] {
193 const groups = (['working', 'waiting'] as const)
194 .map(kind => ({ kind, list: kindOf(chats, kind).filter(chat => chat.id !== undefined) }))
195 .filter(group => group.list.length > 0)
196 // Each kind gets an equal share of the row; what does not fit is counted.
197 const share = (width - 24) / Math.max(1, groups.length)
198 return groups.map(({ kind, list }) => {
199 let room = share - kindSvg(kind, false).width - 36
200 const shown: ChatState[] = []
201 for (const chat of list) {
202 if (buttonWidth(chat) > room) break
203 shown.push(chat)
204 room -= buttonWidth(chat) + 6
205 }
206 return { kind, shown, rest: list.length - shown.length }
207 })
208}
209hooks/rules.ts 88 lines1// What counts as dangerous: a shell command that destroys what cannot be got
2// back, and a file that holds secrets. Each rule says, in plain words, what
3// would happen, for the question the person is asked.
4//
5// The rules read the command's text. A command built at run time (a name
6// kept in a variable, a script written to a file first) is not seen.
7
8export type Danger = { what: string }
9
10type Rule = { test: RegExp; what: string }
11
12// A flag that means "and everything inside": `-r`, `-rf`, `-fR`, `--recursive`,
13// and PowerShell's `-Recurse` and its short forms. `-Force` is not one.
14const RECURSIVE = String.raw`(?<!\S)(?:-(?!force\b)[a-z]{0,3}r[a-z]{0,3}|--recursive|-rec[a-z]*)(?=\s|$)`
15
16// The delete commands of bash, cmd and PowerShell, including its short names.
17const REMOVE = new RegExp(String.raw`(?:^|[\s;&|("'\x60])(?:rm|ri|del|erase|rd|rmdir|Remove-Item)\s+(?=[^|;&]*${RECURSIVE})`, 'i')
18
19// A short flag cluster holding `f`, as in `-f` and `-uf`.
20const PUSH_FORCE = /\bgit\s+push\b[^|;&]*(\s--force\b|\s-(?!-)[a-z]*f[a-z]*(?=\s|$)|\s\+\S|\s--mirror\b)/i
21const PUSH_DELETE = /\bgit\s+push\b[^|;&]*(\s--delete\b|\s-(?!-)[a-z]*d[a-z]*(?=\s|$)|\s:\S)/i
22
23const SHELLS = String.raw`(sh|bash|zsh|iex|Invoke-Expression|python[\d.]*|node|perl|ruby)`
24
25const COMMANDS: Rule[] = [
26 { test: REMOVE, what: 'フォルダごと消す' },
27 { test: /\b(rd|rmdir)\s+[^|;&]*\/s\b/i, what: 'フォルダごと消す' },
28 { test: /\bdel\s+[^|;&]*\/s\b/i, what: 'フォルダの中のファイルをまとめて消す' },
29 { test: /\bfind\b[^|;&]*\s(-delete\b|-exec(dir)?\s+rm\b)/i, what: '見つけたファイルをまとめて消す' },
30 { test: /\|\s*xargs\s+(-\S+\s+)*rm\b/i, what: '見つけたファイルをまとめて消す' },
31 { test: /\b(mv|move|Move-Item)\b[^|;&]*\s(\/dev\/null|NUL|\$null)(?=\s|$)/i, what: 'ファイルを行き先のない場所へ移して消す' },
32 {
33 test: /\b(shutil\.rmtree|rimraf|FileUtils\.rm_r?f?|rmSync\([^)]*recursive|fs(\.promises)?\.rm(dir)?\([^)]*recursive|Directory\.Delete\([^)]*true)/i,
34 what: 'プログラムを使ってフォルダごと消す',
35 },
36 { test: PUSH_FORCE, what: 'GitHub の履歴を上書きする' },
37 { test: PUSH_DELETE, what: 'GitHub のブランチを消す' },
38 { test: /\bgit\s+(filter-branch|filter-repo)\b/i, what: '履歴をまるごと書き換える' },
39 { test: /\bgit\s+reset\s+[^|;&]*--hard\b/i, what: 'まだ保存していない変更を捨てる' },
40 { test: /\bgit\s+clean\s+[^|;&]*-\w*f/i, what: '管理していないファイルを消す' },
41 { test: /\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)/i, what: 'まだ保存していない変更を捨てる' },
42 { test: /\bgit\s+branch\s+[^|;&]*-D\b/, what: 'ブランチを強制的に消す' },
43 { test: /\b(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE\s+TABLE)\b/i, what: 'データベースの中身を消す' },
44 { test: /\bformat(\.com)?\s+[a-z]:|\b(diskpart|mkfs(\.\w+)?|Format-Volume|Clear-Disk)\b/i, what: 'ドライブを初期化する' },
45 { test: /\bdd\s+[^|;&]*\bof=\/dev\//i, what: 'ディスクに直接書き込む' },
46 {
47 test: new RegExp(String.raw`\b(curl|wget|iwr|Invoke-WebRequest|irm|Invoke-RestMethod)\b[^|]*\|\s*${SHELLS}\b`, 'i'),
48 what: 'ネットから落としたものをそのまま実行する',
49 },
50 {
51 test: new RegExp(String.raw`\bbase64\s+(-d|-D|--decode)\b[^|]*\|\s*${SHELLS}\b|FromBase64String[\s\S]*\b(iex|Invoke-Expression)\b|\b(powershell|pwsh)(\.exe)?\b[^|;&]*\s-e(nc|ncodedcommand)?\s`, 'i'),
52 what: '中身を隠した命令をそのまま実行する',
53 },
54 { test: /\b(reg\s+delete|Remove-ItemProperty\s+[^|;]*HK(LM|CU))\b/i, what: 'Windows の設定(レジストリ)を消す' },
55 { test: /\bshutdown(\.exe)?\s+[/-][srh]\b|\b(Stop-Computer|Restart-Computer)\b/i, what: 'パソコンを止める・再起動する' },
56]
57
58// A program that runs the text it is given: what is quoted after it is a command too.
59const RUNS_QUOTED =
60 /(?:^|[\s;&|(])(bash|sh|zsh|dash|fish|cmd(\.exe)?|powershell(\.exe)?|pwsh|eval|iex|Invoke-Expression|Invoke-Command|Start-Process|python[\d.]*|py|node|deno|bun|ruby|perl|php|ssh|watch|su|sudo|wsl|xargs|psql|mysql|mariadb|sqlite3|sqlcmd|mongo|mongosh|redis-cli)(?=[\s;&|)]|$)/i
61
62// Quoted text is only words: a message that mentions `rm -rf` is not that
63// command, and `rm -rf "my dir"` is still one. A quoted word without spaces
64// is kept as the word, and `$(…)` inside double quotes runs, so it is kept.
65function unquoted(command: string): string {
66 return command.replace(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g, (all, double?: string, single?: string) => {
67 const inner = double ?? single ?? ''
68 if (double !== undefined && /\$\(|`/.test(inner)) return all
69 return /\s/.test(inner) ? '""' : inner
70 })
71}
72
73// Files that hold keys or passwords, by name.
74const SECRETS =
75 /(^|[\\/])(\.env(\.[\w.-]+)?|id_(rsa|ed25519|ecdsa)(\.pub)?|[^\\/]+\.(pem|key|p12|pfx|jks|keystore)|credentials(\.json)?|\.git-credentials|secrets?\.(json|ya?ml|toml|txt|ini|env)|[^\\/]*service[-_]?account[^\\/]*\.json|[^\\/]*adminsdk[^\\/]*\.json|\.npmrc|\.netrc|\.pypirc|\.htpasswd)$|[\\/]\.ssh[\\/]/i
76
77export function dangerOf(command: string): Danger | null {
78 const bare = unquoted(command)
79 const seen = RUNS_QUOTED.test(bare) ? command : bare
80 const rule = COMMANDS.find(({ test }) => test.test(seen))
81
82 return rule === undefined ? null : { what: rule.what }
83}
84
85export function isSecret(path: string): boolean {
86 return SECRETS.test(path)
87}
88hooks/glow.ts 44 lines1// The slow glow kokot's mods share: a light drifting sideways through a
2// gradient, and a soft halo that swells and fades.
3//
4// They move only while `isMoving`. A page with anything moving on it is drawn
5// again every frame, which measured about a quarter of a core however small
6// the motion, and about the same for four strips as for one. So they move
7// while the app is moving anyway (Claude at work, a render running) and rest,
8// still lit, otherwise.
9
10// What it fills seems to glow: `base` at its ends, `bright` in its middle.
11// `shift` starts each at its own point of the drift.
12export function sheen(id: string, base: string, bright: string, isMoving: boolean, shift = 0): string {
13 const drift = isMoving
14 ? `<animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="6s" begin="-${shift}s" repeatCount="indefinite"/>`
15 : ''
16 return (
17 `<linearGradient id="${id}" x1="0" y1="0" x2="1" y2="0" spreadMethod="reflect">` +
18 `<stop offset="0" stop-color="${base}"/><stop offset="0.5" stop-color="${bright}"/><stop offset="1" stop-color="${base}"/>` +
19 `${drift}</linearGradient>`
20 )
21}
22
23// A soft halo around what it is put on, swelling and fading while moving.
24export function breathe(id: string, isMoving: boolean): string {
25 const swell = isMoving ? `<animate attributeName="stdDeviation" values="0.6;2.4;0.6" dur="4s" repeatCount="indefinite"/>` : ''
26 return (
27 `<filter id="${id}" x="-50%" y="-80%" width="200%" height="260%">` +
28 `<feGaussianBlur stdDeviation="1.2" result="b">${swell}</feGaussianBlur>` +
29 `<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`
30 )
31}
32
33// A line with one patch of light crossing it slowly, left to right; at rest,
34// the plain line.
35export function crossing(id: string, color: string, track: string, isMoving: boolean): string {
36 if (!isMoving) return `<linearGradient id="${id}"><stop offset="0" stop-color="${track}"/></linearGradient>`
37 return (
38 `<linearGradient id="${id}" x1="0" y1="0" x2="1" y2="0">` +
39 `<stop offset="0.3" stop-color="${track}"/><stop offset="0.5" stop-color="${color}"/><stop offset="0.7" stop-color="${track}"/>` +
40 `<animateTransform attributeName="gradientTransform" type="translate" from="-0.7 0" to="0.7 0" dur="9s" repeatCount="indefinite"/>` +
41 `</linearGradient>`
42 )
43}
44types/index.d.ts 45 lines1/** One thing the shield did, newest last in the log. */
2export type ShieldEvent = {
3 /**
4 * `stopped`: a dangerous operation the person said no to (or nobody could
5 * be asked); `allowed`: one they let through; `ok`: a file checked clean;
6 * `broken`: a file with a syntax error, handed back to Claude to fix.
7 */
8 kind: 'stopped' | 'allowed' | 'ok' | 'broken'
9 /** What it was about: the command, or the file's name and line. */
10 subject: string
11 /** What would have happened, or what is wrong, in plain words. */
12 detail: string
13 /** When, in ms. */
14 at: number
15}
16
17export type ShieldTally = {
18 stopped: number
19 allowed: number
20 checked: number
21 broken: number
22}
23
24/** Another chat that is working, or has an answer you have not read. */
25export type ChatState = {
26 /** The app's id for the chat, `local_…`: what opens it. Absent in a scan older than it. */
27 id?: string
28 title: string
29 state: 'working' | 'waiting'
30 /** Its last activity, in ms. */
31 at: number
32}
33
34declare module 'claude-code' {
35 interface PluginState {
36 shield: {
37 /** The latest event, or none yet this session. */
38 last: ShieldEvent | null
39 tally: ShieldTally
40 /** The other chats at work. */
41 others: ChatState[]
42 }
43 }
44}
45