SLOPSHOPPER

shield

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

newbandguardtoastprocesstimer
v0.6.0no licenseupdated 2026-10-26kokotosu/claude-mods/shield
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shield
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM SHIELD 確認して実行: rm -rf build && git push --force origin main / 止めた0 確認して実行1 チェック0 エラー0 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
SHIELD 確認して実行: rm -rf build && git push --force origin main / 止めた0 確認して実行1 チェック0 エラー0 ⟨Claude Code's own drawing⟩
README

claude-mods

kokot が作った Claude Code 用の mod 集です。

modできること
usage-band入力欄の上に、残りの使用量(5時間枠・週枠・コンテキスト)とリセットまでの時間を帯で表示する。7日間のトークンを何に使ったかの円グラフも出す。チャット名の色分けもする
chat-timeLINE のように、各メッセージの下に送った日時を表示する
ja-questionsClaude からの質問が英語なら、日本語に訳して表示する
push-to-talk決めたキーを押している間だけ録音して、話した内容を入力欄に入れる
shield危ない操作(フォルダごと削除・履歴の上書き・秘密ファイルの書き換えなど)は止めて確認し、Claude が書いたファイルは自動で構文チェックする。ほかの作業中のチャットも表示し、名前を押すとそのチャットへ飛べる。守るもの・守れないものは shield/SAFETY.md

別のパソコンに入れる方法

ログインはいりません。ほしい mod だけ選んで入れられます。

  1. ターミナルで、まず棚の登録を打つ(1回だけ)
  2. そのあと、ほしい 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
  1. Claude を開き直す

Claude に頼むなら「GitHub の kokotosu/claude-mods から chat-time だけ入れて」のように言えば入れてくれる。

更新したとき

claude plugin marketplace update kokot-claude-mods

そのあと claude plugin update <mod名>@kokot-claude-mods を打つ。

注意

  • chat-time と usage-band の円グラフは Node.js が要る
  • push-to-talk は Windows 専用
  • shield のチャットへ飛ぶボタンは、パソコンの Claude アプリで押したときだけ効く
  • チャット名の色分けを前のパソコンと同じにしたいときは、~/.claude/mods/chat-colors.json もコピーする
Source 7 files
hooks/register.tsx 225 lines
1import 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}
225
hooks/check.ts 152 lines
1// 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}
152
hooks/chats.ts 120 lines
1// 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}
120
hooks/draw.ts 209 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
209
hooks/rules.ts 88 lines
1// 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}
88
hooks/glow.ts 44 lines
1// 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}
44
types/index.d.ts 45 lines
1/** 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