/jikkyo で Claude の動きをスポーツ実況風にステータス行とペインで伝える実況席

Claude Code の関数フック mod を集めたリポジトリです。 ペイン・入力欄の上の帯・枠・トーストを使い、VS Code 拡張でもターミナルでも見た目が崩れないように作っています。 VS Code では SVG のグラフやアニメ(SMIL / CSS)も描けます。
Anthropic とは関係のない、個人が作った非公式の mod です。
| mod | 呼び方 | 何をするか |
|---|---|---|
| mod-hub | /mod | 画面のある mod(ペインや帯を出す物)をアイコン付きで並べる。名前・説明で絞る、固定して上に置く、カテゴリー別(開発・状況・道具・遊び)・名前順・最近開いた順。見出しに季節の小さな飾り |
| cc-dash | /dash | セッションの計器盤。経過・ターン・ツール・エラーのタイル、ターンの所要時間の棒グラフ、ツールのタイムライン、使用量のリング型ゲージ。「自分の並び」で出す枠と順を自分で決められる |
| work-bar | 入力欄の上に自動で出る | 作業中は経過時間・今のツール・流れる光の帯。待ち中は前のターンのまとめと定型文のボタン |
| aqua-pet | /aquarium | 作業に反応する水槽。魚は 1 匹ずつターンで 稚魚 → 若魚 → 成魚 → 大物 と育ち、節目で新しい魚(全 10 種、後ほど珍しい)が来る。エラーで赤く震える。えさ・名前・掃除・魚の一覧のボタン。日本時間の時刻で水の色と光が(夕は橙で沈む日、夜は月と星)、季節で飾り(花びら・泡・落ち葉・雪)が変わる。turn-gacha で集めたカードでかざり(水草・石・流木・貝・つぼ・城・宝箱・沈没船)が手に入り、底の 5 つの置き場に置ける(城と沈没船の周りを魚が回る)。記録は毎日控えを取り、ペインから戻せる |
| project-board | /projects | フォルダに並んだ引き継ぎメモ(*.md)の鮮度グラフと一覧・詳細 |
| git-pane | /git | 今のフォルダの git の様子。ブランチ・変更行数の差分バー・直近コミット。git には書き込まない |
| token-meter | /tokens | ターンごとのトークンの積み上げ棒とキャッシュの当たり率 |
| focus-timer | /focus-timer | ポモドーロ。減っていくリングと今日の本数 |
| session-recap | /session-recap | セッションのふりかえり。ターンの帯のタイムラインと、押した時だけ作る要約 |
| edit-log | /edits | このセッションで Claude が Edit / Write などの編集ツールで変えたファイルの時系列。押すと git の差分(HEAD と比べる)と「レビューして」 |
| todo-board | /todos | 今のフォルダの TODO / FIXME / HACK / XXX の件数・推移・一覧。git の外はフォルダを歩いて読む。種類のボタンで絞り、押すと「片付けて」 |
| test-runner | /testrun | 設定したテストのコマンドをボタンで走らせる。信号と直近 20 回の結果、失敗したら「直して」。Claude が走らせたテストも拾い、落ちている間は入力欄の上に 1 行出す。落ちたままコミットしようとしたらトーストで知らせる |
| prompt-book | /prompts | よく使う依頼文を保存して、ボタンで入力欄へ。タグ・使った回数・並び替え |
| live-commentary | /jikkyo | Claude の動きをスポーツ実況風にステータス行へ。口調は熱血・冷静・関西 |
| turn-gacha | /gacha | ターンが終わるたびにガチャ。N / R / SR / SSR のカード 60 種と図鑑、集まり具合の実績 6 個。出たカードは dev-rpg で装備にできる。daily-quest のお題をこなすと、おまけで引ける。記録は毎日控えを取り、「控えから戻す」で戻せる |
| dev-rpg | /rpg | 作業すると経験値がたまる RPG。レベル・称号・実績 22 個。ターンを終えるとたまる「魔物の気配」で魔物 8 種・ボス 3 体とボタンで戦う。ボスは時々湧き、湧いたボスは気配を使わずに挑めて経験値 1.5 倍。戦いで減った HP は 15 分休むか新しいセッションで戻る。レベル・実績・勝利・ボスで入る SP を能力(力・体力・知恵・敏捷)に振り、スキル 10 種を覚える。turn-gacha のカードを武器・防具・お守りに装備して能力が上がる。daily-quest のお題をこなすと経験値が入る。ターンで潜るダンジョン(8 ターンで 1 階、10 階ごとに番人、5 階ごとに休み場)で一番深い階を目指し、勝った分は「帰る」で持ち帰る。記録は毎日控えを取れる |
| code-omikuji | /omikuji | 今日のコード運勢。筒を振ると棒の番号が回って止まり、和紙のおみくじが開く。文は 238 通りと、その季節の日だけ出る一言 |
| agent-watch | /agent-watch | 動いているサブエージェントの帯のタイムライン(経過・ツール回数・状態)。ボタンでその会話へ切り替え、メインへ戻る |
| canvas-pane | /canvas | Claude が呼べるツール show を足す。「売上をグラフにしてペインに出して」と頼むと、棒・折れ線・円・表・Markdown・SVG をペインに描く。直近 20 枚をめくれる |
| rule-switch | /rules | 「日本語で答える」「commit しない」などの約束ごとをボタンでオン/オフし、システムプロンプトに入れる。自分の約束も足せる |
| wait-games | /games | 長いターンの間に遊ぶ 2048・マインスイーパー・15 パズル・ライツアウト・数独(4x4 / 9x9)・神経衰弱。ゲームごとに勝ち数・最短手数・最速を残す。ターンが終わったら知らせる。記録は毎日控えを取り、「控えから戻す」で戻せる |
| spec-board | /specs | SpecStatus の確認待ち・途中の仕様書と次のおすすめ。押すと詳細(読むだけ) |
| ci-pane | /ci | 今のブランチの PR(状態・レビュー・チェックの丸)と GitHub Actions の結果。gh を使う。何かを起こす操作は無い |
| port-watch | /ports | 待ち受け中の TCP ポートと、セッション中に増えた物の印。URL を写すボタンと、開発用の実行環境だけ 2 回押しで止めるボタン |
| pre-check | /precheck | コミット前の確かめ。変更の要約・秘密情報らしい文字列(足した行だけ、伏せて表示)・衝突の印・1MB 超のファイル・テスト(ボタンで) |
| face-band | /face で出す / 隠す | 待ち・考え中・ツール実行中・答え終わり・エラーで変わる Claude の顔。日本時間の時間帯と季節で小物が変わる(夜は眠そうにナイトキャップ、夏は麦わら帽子、冬はマフラーなど)。入れただけでは出ない(最初はオフ)。work-bar と並べて出る |
| done-chime | /chime | 長いターン(既定 30 秒以上)が終わったら音を鳴らす。音の種類・しきい値・音量・試し鳴らし |
| turn-haiku | /haiku | ターンが終わるたびに、やった事を五七五の 1 句にして縦書きの短冊に。季節の季語の候補を添えて詠む。過去の句の一覧 |
| session-timeline | /timeline | このセッションのターンとツール呼び出しを横の帯の年表で。ツールの種類で色分け、失敗は赤い印、サブエージェントは細い帯、1 分以上のターンは黄の縁 |
| file-map | /filemap | このセッションで読んだ・書いたファイルを、フォルダごとの四角(ツリーマップ)で。大きいほど多く触った所、橙は書いた物、青は読んだだけ |
| toast-inbox | /inbox | ほかの mod が出したトーストを 100 件まで残す受け箱(設定でステータス行も)。未読の数・mod ごとの件数・mod で絞る・全部既読・空にする。通知の行を押すと、それを出した mod のペインを開く |
| usage-history | /history | 日をまたいだ使い方の記録。日ごとのターン数か出力トークンの棒(7 日 / 30 日)、合計と 1 日平均、曜日と時間帯のヒートマップ。直近 7 日の数をほかの mod(week-card)にも見せる。記録は毎日控えを取れる |
| fail-log | /errors | ツールが失敗した記録を 200 件まで残す。似た失敗をまとめた「よく落ちる物」の棒、最近の一覧、今日・通算の数。サブエージェントの分は紫で分ける |
| daily-quest | /quest | 日替わりのお題 3 つ(ターン・ファイルを直す・テスト・コミットなど 8 種から)。進みの棒・ごほうび・連続日数・直近 7 日。ごほうびは dev-rpg が経験値、turn-gacha がおまけの抽選で受け取る。記録は毎日控えを取り、「控えから戻す」で戻せる |
| trophy-shelf | /trophies | dev-rpg・turn-gacha・aqua-pet・wait-games・daily-quest の記録を 1 枚の棚にまとめ、トロフィーを初めて取った日付つきで集める。ダンジョンの深さのトロフィー 3 つ(番人越え・地下三十階・百階の主)も |
| mod-check | /mod-check | mod がちゃんと動いているかを 1 回で確かめる。入っている・コマンドあり・予備名・見つからないの数と、mod ごとの判定(本来の名前か予備名か、本来の名前を使っている相手)、画面・テーマ・Claude Code の版をペインに出し、同じ結果を 1 行 1 mod の貼れる文で返す。隣の mod の版がセッション中に変わったら、帯とトーストで開き直しを頼む。「点検表」タブで実画面の見え方に よい・だめ・まだ を付け、結果を貼る文にする |
| compact-cue | 入力欄の上に自動で出る・/cue | コンテキストの使用量が多くなった時、テストが通った・コミットした・引き継ぎメモを書いた直後なら「今が /compact のよい時」、編集の途中なら「区切りまで待って」と帯とトーストで知らせる。ペインは使用量のリングと作業の様子。知らせるだけで compact はしない |
| ship-guard | 常時・/guard | Claude が git push・gh release・gh repo create・npm publish を打つ時、直前に AskUserQuestion で公開してよいと答えていなければ、その場で「実行しますか?」と聞き、断られたら止める。ペインは記録とオン/オフ。git push の前は、送るコミットの作者が noreply か、足した行に個人のメール・ユーザー名入りのパスが無いかも確かめ、引っかかったら確認済みでも聞き直す |
| rule-watch | 入力欄の上に自動で出る・/rulewatch | Claude の返事が約束ごとから外れた時に帯で知らせる。英語で答えた・AskUserQuestion を使わずに「〜しますか?」で終わった・コミットの後に引き継ぎメモや開発ログを書いていない・開発ログを書いたのに「📝」の 1 行が無い・締めの言葉なのに開発ログが無い、の 6 つ。ペインは数と記録、「Claude に伝える」で入力欄に一言入れる |
| enc-guard | 常時・/enc | 文字コード・改行の罠を Claude が踏んだ時に、直し方を Claude に伝える(設定で止めるにもできる)。-Encoding の無い Set-Content / Add-Content、日本語入りで BOM の無い .ps1、ASCII 以外の字が入った .bat / .cmd、Windows で encoding= / newline= の無い Python の open |
| release-draft | /release | 前のタグから今までのコミットと、版が上がった plugin.json / package.json を集めて、CHANGELOG の下書き(Added / Changed / Fixed)と次の版の候補を出す。「これで CHANGELOG を書いて」で Claude に頼む。git は読むだけ |
| reply-frame | 常時 | 返答をオレンジ、自分の発言を青の角丸の枠で囲む |
| danger-frame | 常時 | 危ないシェルのコマンドの行を赤い枠で囲む |
| long-turn-toast | 常時 | 長くかかったターン(既定 120 秒超)をトーストで知らせる |
| usage-pane | /usage-pane | 使用量の JSON を 3 本のバーで表示 |
| devlog-pane | /devlog | フォルダにある今日の開発ログ(YYYY-MM-DD.md)を表示 |
| run-guard | 常時・/runguard | Claude が確かめのために窓のあるアプリを起動する・音を鳴らす・claude -p を隔離なしで走らせるのを、実行の前に止めて直し方(-WindowStyle Hidden・--headless・一時の設定フォルダ)を返す。組ごとにオン/オフでき、記録も残る |
| week-card | /week | 1 週間の使い方と、お題・ガチャの図鑑・冒険の書(レベルとダンジョン)・水槽の増え方を 1 枚のカードにまとめ、週報に貼る文(Markdown)を入力欄に入れる |
reply-frame は takosasi-dev/reply-frame でも公開しています。ここに入っているのは、自分の発言の枠を足した新しい版です。
VS Code のペインに描かれる SVG の部分です(架空のデータ。ボタンなどは省いています)。node tools/shots.mts で作り直せます(--light で明るいテーマ)。
<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub.png" width="250" alt="mod-hub"></td> <td align="center" valign="top"><b>cc-dash</b><br><img src="docs/images/cc-dash.png" width="250" alt="cc-dash"></td> <td align="center" valign="top"><b>work-bar</b><br><img src="docs/images/work-bar.png" width="250" alt="work-bar"></td> </tr> <tr> <td align="center" valign="top"><b>face-band</b><br><img src="docs/images/face-band.png" width="250" alt="face-band"></td> <td align="center" valign="top"><b>aqua-pet</b><br><img src="docs/images/aqua-pet.png" width="250" alt="aqua-pet"></td> <td align="center" valign="top"><b>project-board</b><br><img src="docs/images/project-board.png" width="250" alt="project-board"></td> </tr> <tr> <td align="center" valign="top"><b>git-pane</b><br><img src="docs/images/git-pane.png" width="250" alt="git-pane"></td> <td align="center" valign="top"><b>token-meter</b><br><img src="docs/images/token-meter.png" width="250" alt="token-meter"></td> <td align="center" valign="top"><b>focus-timer</b><br><img src="docs/images/focus-timer.png" width="250" alt="focus-timer"></td> </tr> <tr> <td align="center" valign="top"><b>session-recap</b><br><img src="docs/images/session-recap.png" width="250" alt="session-recap"></td> <td align="center" valign="top"><b>edit-log</b><br><img src="docs/images/edit-log.png" width="250" alt="edit-log"></td> <td align="center" valign="top"><b>todo-board</b><br><img src="docs/images/todo-board.png" width="250" alt="todo-board"></td> </tr> <tr> <td align="center" valign="top"><b>test-runner</b><br><img src="docs/images/test-runner.png" width="250" alt="test-runner"></td> <td align="center" valign="top"><b>pre-check</b><br><img src="docs/images/pre-check.png" width="250" alt="pre-check"></td> <td align="center" valign="top"><b>ci-pane</b><br><img src="docs/images/ci-pane.png" width="250" alt="ci-pane"></td> </tr> <tr> <td align="center" valign="top"><b>spec-board</b><br><img src="docs/images/spec-board.png" width="250" alt="spec-board"></td> <td align="center" valign="top"><b>port-watch</b><br><img src="docs/images/port-watch.png" width="250" alt="port-watch"></td> <td align="center" valign="top"><b>prompt-book</b><br><img src="docs/images/prompt-book.png" width="250" alt="prompt-book"></td> </tr> <tr> <td align="center" valign="top"><b>agent-watch</b><br><img src="docs/images/agent-watch.png" width="250" alt="agent-watch"></td> <td align="center" valign="top"><b>canvas-pane</b><br><img src="docs/images/canvas-pane.png" width="250" alt="canvas-pane"></td> <td align="center" valign="top"><b>rule-switch</b><br><img src="docs/images/rule-switch.png" width="250" alt="rule-switch"></td> </tr> <tr> <td align="center" valign="top"><b>live-commentary</b><br><img src="docs/images/live-commentary.png" width="250" alt="live-commentary"></td> <td align="center" valign="top"><b>turn-gacha</b><br><img src="docs/images/turn-gacha.png" width="250" alt="turn-gacha"></td> <td align="center" valign="top"><b>dev-rpg</b><br><img src="docs/images/dev-rpg.png" width="250" alt="dev-rpg"></td> </tr> <tr> <td align="center" valign="top"><b>code-omikuji</b><br><img src="docs/images/code-omikuji.png" width="250" alt="code-omikuji"></td> <td align="center" valign="top"><b>turn-haiku</b><br><img src="docs/images/turn-haiku.png" width="250" alt="turn-haiku"></td> <td align="center" valign="top"><b>wait-games</b><br><img src="docs/images/wait-games.png" width="250" alt="wait-games"></td> </tr> <tr> <td align="center" valign="top"><b>session-timeline</b><br><img src="docs/images/session-timeline.png" width="250" alt="session-timeline"></td> <td align="center" valign="top"><b>file-map</b><br><img src="docs/images/file-map.png" width="250" alt="file-map"></td> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history.png" width="250" alt="usage-history"></td> </tr> <tr> <td align="center" valign="top"><b>toast-inbox</b><br><img src="docs/images/toast-inbox.png" width="250" alt="toast-inbox"></td> <td align="center" valign="top"><b>done-chime</b><br><img src="docs/images/done-chime.png" width="250" alt="done-chime"></td> <td align="center" valign="top"><b>fail-log</b><br><img src="docs/images/fail-log.png" width="250" alt="fail-log"></td> </tr> <tr> <td align="center" valign="top"><b>daily-quest</b><br><img src="docs/images/daily-quest.png" width="250" alt="daily-quest"></td> <td align="center" valign="top"><b>trophy-shelf</b><br><img src="docs/images/trophy-shelf.png" width="250" alt="trophy-shelf"></td> <td align="center" valign="top"><b>mod-check</b><br><img src="docs/images/mod-check.png" width="250" alt="mod-check"></td> </tr> <tr> <td align="center" valign="top"><b>compact-cue</b><br><img src="docs/images/compact-cue.png" width="250" alt="compact-cue"></td> <td align="center" valign="top"><b>ship-guard</b><br><img src="docs/images/ship-guard.png" width="250" alt="ship-guard"></td> <td align="center" valign="top"><b>rule-watch</b><br><img src="docs/images/rule-watch.png" width="250" alt="rule-watch"></td> </tr> <tr> <td align="center" valign="top"><b>enc-guard</b><br><img src="docs/images/enc-guard.png" width="250" alt="enc-guard"></td> <td align="center" valign="top"><b>release-draft</b><br><img src="docs/images/release-draft.png" width="250" alt="release-draft"></td> <td align="center" valign="top"><b>run-guard</b><br><img src="docs/images/run-guard.png" width="250" alt="run-guard"></td> </tr> <tr> <td align="center" valign="top"><b>week-card</b><br><img src="docs/images/week-card.png" width="250" alt="week-card"></td> </tr> </table>
ペインの絵は開いた時に 1 回動きます(数字が増える・棒が伸びる・おみくじが開く)。node tools/gif.mts <mod> で作り直せます。
<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub.gif" width="250" alt="mod-hub(動く絵)"></td> <td align="center" valign="top"><b>cc-dash</b><br><img src="docs/images/cc-dash.gif" width="250" alt="cc-dash(動く絵)"></td> </tr> <tr> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history.gif" width="250" alt="usage-history(動く絵)"></td> <td align="center" valign="top"><b>code-omikuji</b><br><img src="docs/images/code-omikuji.gif" width="250" alt="code-omikuji(動く絵)"></td> </tr> </table>
Claude Code の /config で theme を明るい物(light で始まる物)にすると、SVG も明るい色で描きます(VS Code の配色は mod から読めないので、Claude Code の theme に合わせます)。
<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub-light.png" width="250" alt="mod-hub(明るいテーマ)"></td> <td align="center" valign="top"><b>git-pane</b><br><img src="docs/images/git-pane-light.png" width="250" alt="git-pane(明るいテーマ)"></td> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history-light.png" width="250" alt="usage-history(明るいテーマ)"></td> </tr> </table>
好きな mod だけ入れられます。冒険の書(dev-rpg)とガチャ(turn-gacha)の 2 本だけが欲しい時は、単体版の claude-code-dev-rpg からも入れられます(中身は同じなので、どちらか片方から入れてください)。ターミナルの Claude Code の入力欄で:
/plugin install cc-dash --marketplace takosasi-dev/claude-code-mods
y でマーケットプレイスを足し、スコープを選ぶと入ります。シェルからは:
claude plugin install cc-dash --marketplace takosasi-dev/claude-code-mods
デスクトップアプリでは、入力欄の横の + → Plugins → Add marketplace に takosasi-dev/claude-code-mods を入れ、Add plugin で選びます。
mod-hub に並ぶのは、mod-hub より後に読み込まれる mod だけです。mod-hub を最初に入れてください。カテゴリーとアイコンはこのリポジトリの mod だけに付いていて、ほかの mod は「その他」に入ります。toast-inbox と mod-check も、ほかの mod より後から読み込まれる物を見るので、mod-hub の次に入れるのがおすすめです。
コマンドの名前がほかのプラグインや組み込みと被った時は、/edit-log・/todo-board・/test-runner・/prompt-book・/live-commentary・/turn-gacha・/dev-rpg・/code-omikuji・/gitpane・/token-meter・/mod-hub・/canvas-pane・/rule-switch・/wait-games・/spec-board・/ci-pane・/port-watch・/pre-check・/face-band・/done-chime・/turn-haiku・/session-timeline・/file-map・/toast-inbox・/usage-history・/fail-log・/compact-cue・/ship-guard・/rule-watch・/enc-guard・/release-draft・/daily-quest・/trophy-shelf・/modcheck・/run-guard・/week-card のように mod の名前のコマンドになります。
claude plugin install は 1 回に 1 本だけ受け取ります。まとめて入れる時は、下の行のかたまりをシェル(bash・zsh・PowerShell のどれでも)にそのまま貼ってください。最初の 1 本でマーケットプレイスが足され、2 本目からはそれを使います。スコープは既定で user(全プロジェクト)で、-s project を付けると今のプロジェクトだけに入ります。mod-hub・toast-inbox・mod-check を先に入れてから、ほしいカテゴリーを開いて貼ります。
最初に(mod-hub・toast-inbox・mod-check)
claude plugin install mod-hub --marketplace takosasi-dev/claude-code-mods
claude plugin install toast-inbox --marketplace takosasi-dev/claude-code-mods
claude plugin install mod-check --marketplace takosasi-dev/claude-code-mods
claude plugin install project-board --marketplace takosasi-dev/claude-code-mods
claude plugin install git-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install edit-log --marketplace takosasi-dev/claude-code-mods
claude plugin install todo-board --marketplace takosasi-dev/claude-code-mods
claude plugin install test-runner --marketplace takosasi-dev/claude-code-mods
claude plugin install spec-board --marketplace takosasi-dev/claude-code-mods
claude plugin install ci-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install port-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install pre-check --marketplace takosasi-dev/claude-code-mods
claude plugin install fail-log --marketplace takosasi-dev/claude-code-mods
claude plugin install ship-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install rule-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install enc-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install release-draft --marketplace takosasi-dev/claude-code-mods
claude plugin install run-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install cc-dash --marketplace takosasi-dev/claude-code-mods
claude plugin install work-bar --marketplace takosasi-dev/claude-code-mods
claude plugin install token-meter --marketplace takosasi-dev/claude-code-mods
claude plugin install session-recap --marketplace takosasi-dev/claude-code-mods
claude plugin install agent-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install session-timeline --marketplace takosasi-dev/claude-code-mods
claude plugin install file-map --marketplace takosasi-dev/claude-code-mods
claude plugin install usage-history --marketplace takosasi-dev/claude-code-mods
claude plugin install compact-cue --marketplace takosasi-dev/claude-code-mods
claude plugin install usage-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install devlog-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install focus-timer --marketplace takosasi-dev/claude-code-mods
claude plugin install prompt-book --marketplace takosasi-dev/claude-code-mods
claude plugin install canvas-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install rule-switch --marketplace takosasi-dev/claude-code-mods
claude plugin install done-chime --marketplace takosasi-dev/claude-code-mods
claude plugin install aqua-pet --marketplace takosasi-dev/claude-code-mods
claude plugin install live-commentary --marketplace takosasi-dev/claude-code-mods
claude plugin install turn-gacha --marketplace takosasi-dev/claude-code-mods
claude plugin install dev-rpg --marketplace takosasi-dev/claude-code-mods
claude plugin install code-omikuji --marketplace takosasi-dev/claude-code-mods
claude plugin install wait-games --marketplace takosasi-dev/claude-code-mods
claude plugin install face-band --marketplace takosasi-dev/claude-code-mods
claude plugin install turn-haiku --marketplace takosasi-dev/claude-code-mods
claude plugin install daily-quest --marketplace takosasi-dev/claude-code-mods
claude plugin install trophy-shelf --marketplace takosasi-dev/claude-code-mods
claude plugin install week-card --marketplace takosasi-dev/claude-code-mods
claude plugin install reply-frame --marketplace takosasi-dev/claude-code-mods
claude plugin install danger-frame --marketplace takosasi-dev/claude-code-mods
claude plugin install long-turn-toast --marketplace takosasi-dev/claude-code-mods
全部(48 本)をまとめて(bash / zsh):
for m in mod-hub toast-inbox mod-check cc-dash work-bar aqua-pet project-board git-pane token-meter focus-timer session-recap edit-log todo-board test-runner prompt-book live-commentary turn-gacha dev-rpg code-omikuji agent-watch canvas-pane rule-switch wait-games spec-board ci-pane port-watch pre-check face-band done-chime turn-haiku session-timeline file-map usage-history compact-cue fail-log ship-guard rule-watch enc-guard release-draft daily-quest trophy-shelf reply-frame danger-frame long-turn-toast usage-pane devlog-pane run-guard week-card; do claude plugin install "$m" --marketplace takosasi-dev/claude-code-mods; done
PowerShell:
foreach ($m in 'mod-hub','toast-inbox','mod-check','cc-dash','work-bar','aqua-pet','project-board','git-pane','token-meter','focus-timer','session-recap','edit-log','todo-board','test-runner','prompt-book','live-commentary','turn-gacha','dev-rpg','code-omikuji','agent-watch','canvas-pane','rule-switch','wait-games','spec-board','ci-pane','port-watch','pre-check','face-band','done-chime','turn-haiku','session-timeline','file-map','usage-history','compact-cue','fail-log','ship-guard','rule-watch','enc-guard','release-draft','daily-quest','trophy-shelf','reply-frame','danger-frame','long-turn-toast','usage-pane','devlog-pane','run-guard','week-card') { claude plugin install $m --marketplace takosasi-dev/claude-code-mods }
この一覧は node tools/plugin_dirs.mts --readme で作り直せます(--en で英語版)。
リポジトリを clone して、そのフォルダから読ませることもできます。
takosasi-dev/claude-code-mods を clone 先のパス(例 D:\work\claude-code-mods)に変えて入れます。mod はそのフォルダから直接読まれるので、git pull の後に /reload-plugins で新しい版になりますCLAUDE_CODE_PLUGIN_DIRS で読ませる: 入れずに、セッションを始める時に毎回フォルダから読みます。値は clone 先で node tools/plugin_dirs.mts を走らせると出ます(別の場所を指すなら node tools/plugin_dirs.mts D:\work\claude-code-mods)。mod-hub・toast-inbox・mod-check を先頭に、全 mod のフォルダを Windows は ;、ほかは : でつないだ 1 行です。これを環境変数か、~/.claude/settings.json の env の CLAUDE_CODE_PLUGIN_DIRS に入れます(道具は settings.json を書き換えません)。要らない mod はその行から消してくださいD:\work\claude-code-mods\mods\mod-hub;D:\work\claude-code-mods\mods\toast-inbox;D:\work\claude-code-mods\mods\cc-dash;...
/haiku の「自動で詠む」で止められますtaskkill /T /F)。node で動く MCP サーバーも開発用に見えるので、止める前に名前とポートを確かめてください。macOS / Linux(lsof)の実機では未確認ですgit config --global --add safe.directory <フォルダ> を足してください$.ui.toast / $.ui.status を呼んだ時だけです。「空にする」はほかのセッションの記録も消しますgit grep で読みます(.gitignore の物は読みません)。git の外では作業フォルダを歩いて読み(2000 ファイル・5000 件・8 秒で止め、1 MiB を超えるファイルと . で始まるフォルダ・node_modules・dist などは読みません)、この時はファイルを変えても自動では読み直さないので「再読込」を押してくださいnpm test・pytest など)で見ます。ごほうびは dev-rpg と turn-gacha がターンの終わりに 1 回だけ受け取りますhooks/register.tsx 201 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { LiveCommentaryClock, LiveCommentaryLine, LiveCommentaryScore } from '../types'
5import type { Scene, Style } from './phrases'
6import { KIND, PHRASES, duration, fileOf, fill, pickIndex, sceneOf, toStyle } from './phrases'
7import { KIND_THEME, boardSvg, clockLabel, stadiumSvg, tickerSvg } from './svg'
8import { followTheme, mayWrite, readTheme, rememberVersion } from './style'
9
10const PANE = 'live-commentary'
11const TITLE = '実況席'
12const DESCRIPTION = 'Claude の動きをスポーツ実況風に伝える実況席を開く'
13const STORE_KEY = 'isOn'
14const LOG_MAX = 100
15/** ターンが終わってからステータス行を消すまで */
16export const CLEAR_MS = 20_000
17/** これより長いターンの決着はトーストで知らせる */
18export const LONG_MS = 5 * 60_000
19
20const LOG = atom({ plugin: 'live-commentary', key: 'log' } as const, [] as LiveCommentaryLine[])
21const SCORE = atom({ plugin: 'live-commentary', key: 'score' } as const, { ok: 0, err: 0, streak: 0 } as LiveCommentaryScore)
22const CLOCK = atom({ plugin: 'live-commentary', key: 'clock' } as const, { startedAt: 0, now: 0, lastMs: 0 } as LiveCommentaryClock)
23const ON = atom({ plugin: 'live-commentary', key: 'isOn' } as const, true)
24
25type $ = EngineInterface
26
27// 口調は register で userConfig から入れる
28let style: Style = '熱血'
29// ponytail: 描画が読まない値はモジュール変数。リロードで消えても、同じ言い回しが 1 回続くか、立て直しを 1 回言い逃すだけ
30const lastPick = new Map<string, number>()
31let isAfterError = false
32let clearTimer: Timer | undefined
33// 主の名前 (/jikkyo) を持てたか。session.start で決まる (リロードでも来る)
34let ownsMain = false
35
36function phrase(scene: Scene, vars: Readonly<Record<string, string | number>>): string {
37 const list = PHRASES[style][scene]
38 const key = `${style}:${scene}`
39 const i = pickIndex(list.length, lastPick.get(key) ?? -1, Math.random)
40 lastPick.set(key, i)
41 return fill(list[i] ?? '', vars)
42}
43
44/** 実況する。オフの時は何も言わない (ステータス行もトーストも出さない) */
45async function say($: $, scene: Scene, vars: Readonly<Record<string, string | number>>, isToast = false) {
46 if (!(await read($, ON))) return
47 const line: LiveCommentaryLine = { at: await $.clock.now(), text: phrase(scene, vars), kind: KIND[scene] }
48 await update($, LOG, log => [line, ...log].slice(0, LOG_MAX))
49 $.ui.status(line.text)
50 if (isToast) $.ui.toast(line.text, { timeoutMs: 6000 })
51}
52
53/** オン/オフを切り替える。ステータス行は store より先に消す (書けなくても、オフにしたら消える) */
54const setOn = async ($: $, isOn: boolean) => {
55 await update($, ON, () => isOn)
56 if (!isOn) {
57 clearTimer?.cancel()
58 $.ui.status(undefined)
59 }
60 // 新しい版が入ったのに古い版のまま動いている時は書かない (開き直しのお願いは mayWrite がトーストで出す)
61 if (!(await mayWrite($, await $.clock.now()))) return
62 await $.store.set(STORE_KEY, isOn).catch(() => $.ui.toast('実況のオン/オフを残せませんでした(このセッションでは効いています)', { timeoutMs: 6000 }))
63}
64
65const clearLog = ($: $) => update($, LOG, () => [])
66
67/** ターン中でペインが開いている時だけ、時計を 1 秒ごとに進める */
68async function tick($: $) {
69 const c = await read($, CLOCK)
70 if (c.startedAt === 0) return
71 if (!(await $.ui.panes()).some(p => p.id === PANE)) return
72 const now = await $.clock.now()
73 if (Math.floor((now - c.startedAt) / 1000) === Math.floor((c.now - c.startedAt) / 1000)) return
74 await update($, CLOCK, x => ({ ...x, now }))
75}
76
77const openPane = async ($: $) => {
78 await $.ui.open({ id: PANE, title: TITLE })
79 return { text: '実況席を開きました。' }
80}
81
82export const register: Register = (on, options) => {
83 style = toStyle(options.style)
84 followTheme(on)
85
86 on('session.start', async ($, e, next) => {
87 await rememberVersion($).catch(() => {})
88 // /jikkyo が拒まれたら (組み込みと被ったら) /live-commentary にする
89 const got = await $.command.register({ name: 'jikkyo', description: DESCRIPTION }).catch(() => undefined)
90 ownsMain = got?.command === 'jikkyo'
91 if (!ownsMain) await $.command.register({ name: 'live-commentary', description: DESCRIPTION }).catch(() => {})
92 const saved = await $.store.get(STORE_KEY).catch(() => undefined)
93 await update($, ON, () => saved !== false)
94 $.clock.every(1000, () => void tick($).catch(() => {}))
95 await readTheme($)
96 return next(e)
97 })
98
99 // /jikkyo を持てなかった時は、元の /jikkyo に回す
100 on('command.run', { command: 'jikkyo' }, ($, e, next) => (ownsMain ? openPane($) : next(e))).catch(($, e, next) => next(e))
101 // /jikkyo を持てた時は /live-commentary を登録していないので、その名前の持ち主に回す
102 on('command.run', { command: 'live-commentary' }, ($, e, next) => (ownsMain ? next(e) : openPane($))).catch(($, e, next) => next(e))
103
104 // 主ループのターンだけ実況する (サブエージェントの分は agentId 付き)
105 on('turn.start', async ($, e, next) => {
106 if (e.agentId === undefined) {
107 clearTimer?.cancel()
108 // 前のターンのエラーは持ち越さない
109 isAfterError = false
110 const now = await $.clock.now()
111 await update($, CLOCK, c => ({ ...c, startedAt: now, now }))
112 await say($, 'start', {})
113 }
114 return next(e)
115 })
116
117 on('turn.complete', async ($, e, next) => {
118 if (e.agentId === undefined) {
119 const now = await $.clock.now()
120 await update($, CLOCK, () => ({ startedAt: 0, now, lastMs: e.durationMs }))
121 const time = duration(e.durationMs)
122 if (e.reason === 'aborted' || e.isAborted) await say($, 'abort', { time })
123 else if (e.reason !== 'answer') await say($, 'fail', { time })
124 else if (e.durationMs >= LONG_MS) await say($, 'long', { time }, true)
125 else await say($, 'end', { time })
126 clearTimer?.cancel()
127 clearTimer = $.clock.after(CLEAR_MS, () => $.ui.status(undefined))
128 }
129 return next(e)
130 })
131
132 // 見るだけ。ツールは止めない
133 on('tool.call', async ($, e, next) => {
134 if (e.agentId !== undefined) return next(e)
135 const tool = String(e.tool)
136 const vars = { tool, file: fileOf(e as Record<string, unknown>) }
137 await say($, sceneOf(tool), vars)
138 const ran = await next(e)
139 if ('deny' in ran && ran.deny !== undefined) return ran
140 if (ran.isError) {
141 await update($, SCORE, s => ({ ...s, err: s.err + 1, streak: 0 }))
142 isAfterError = true
143 await say($, 'error', vars)
144 return ran
145 }
146 const s = await update($, SCORE, x => ({ ...x, ok: x.ok + 1, streak: x.streak + 1 }))
147 if (isAfterError) {
148 isAfterError = false
149 await say($, 'comeback', vars, true)
150 } else if (s.streak % 10 === 0) await say($, 'ten', { n: s.streak }, true)
151 else if (s.streak % 5 === 0) await say($, 'streak', { n: s.streak })
152 return ran
153 }).catch(($, e, next) => next(e))
154
155 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
156 const t = $.ui.resolve(e)
157 const { Box, Text, Button } = t
158 // terminal の表にも Svg の名前があるので、画面の種類でも分ける
159 const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : undefined
160
161 const isOn = await read($, ON)
162 const log = await read($, LOG)
163 // 最新の実況はログの先頭
164 const line = log[0] ?? null
165 const score = await read($, SCORE)
166 const clock = await read($, CLOCK)
167 const scoreText = `成功 ${score.ok} / エラー ${score.err} / 連続 ${score.streak}`
168
169 return (
170 <Box flexDirection="column" gap={1}>
171 {Svg ? (
172 <Box key="art" flexDirection="column">
173 <Svg source={stadiumSvg(isOn, style)} alt={`実況席 ${isOn ? `${style} 実況中` : '実況オフ'}`} />
174 <Svg source={boardSvg(score, clock)} alt={`${scoreText} ${clockLabel(clock)}`} />
175 <Svg source={tickerSvg(line, isOn)} alt={line?.text ?? ''} />
176 </Box>
177 ) : (
178 <Box key="head" flexDirection="column">
179 <Text bold>{`実況席 (${style}) ${isOn ? '実況中' : '実況オフ'}`}</Text>
180 <Text>{scoreText}</Text>
181 <Text dimColor>{clockLabel(clock)}</Text>
182 {line !== null && <Text bold color={KIND_THEME[line.kind]}>{line.text}</Text>}
183 </Box>
184 )}
185 <Box flexDirection="row" gap={1}>
186 <Button key="toggle" label={isOn ? '実況オフ' : '実況オン'} variant="primary" onPress={() => void setOn($, !isOn)} />
187 <Button key="clear" dimColor label="ログを消す" onPress={() => void clearLog($)} />
188 </Box>
189 <Box key="log" flexDirection="column">
190 <Text dimColor>{`実況ログ ${log.length} 行 (新しい順)`}</Text>
191 {log.map(l => (
192 <Text color={l.kind === 'error' ? KIND_THEME.error : undefined} dimColor={l.kind === 'play'}>
193 {l.text}
194 </Text>
195 ))}
196 </Box>
197 </Box>
198 )
199 })
200}
201hooks/phrases.ts 467 lines1// 実況の言い回し。口調 × 場面ごとに 5 個ずつ。{tool} {file} {n} {time} を差し込む
2import type { LiveCommentaryKind } from '../types'
3
4export const STYLES = ['熱血', '冷静', '関西'] as const
5export type Style = (typeof STYLES)[number]
6
7export type Scene =
8 | 'start'
9 | 'shell'
10 | 'edit'
11 | 'write'
12 | 'read'
13 | 'search'
14 | 'web'
15 | 'agent'
16 | 'other'
17 | 'error'
18 | 'streak'
19 | 'comeback'
20 | 'ten'
21 | 'end'
22 | 'abort'
23 /** ターンがエラーや拒否で終わった */
24 | 'fail'
25 | 'long'
26
27export const SCENES: readonly Scene[] = ['start', 'shell', 'edit', 'write', 'read', 'search', 'web', 'agent', 'other', 'error', 'streak', 'comeback', 'ten', 'end', 'abort', 'fail', 'long']
28
29/** 場面ごとのログの色の種類 */
30export const KIND: Record<Scene, LiveCommentaryKind> = {
31 start: 'whistle',
32 shell: 'play',
33 edit: 'play',
34 write: 'play',
35 read: 'play',
36 search: 'play',
37 web: 'play',
38 agent: 'play',
39 other: 'play',
40 error: 'error',
41 streak: 'cheer',
42 comeback: 'cheer',
43 ten: 'cheer',
44 end: 'whistle',
45 abort: 'whistle',
46 fail: 'error',
47 long: 'whistle',
48}
49
50export const PHRASES: Record<Style, Record<Scene, readonly string[]>> = {
51 熱血: {
52 start: [
53 'さあ試合開始のホイッスルが鳴りました!',
54 'キックオフ!今日も熱い戦いが始まる!',
55 'さあ始まりました!スタジアムのボルテージは最高潮!',
56 '選手入場!注目の一戦がいま幕を開けます!',
57 'プレイボール!この試合、目が離せません!',
58 'ゴングが鳴った!さあ、どう攻めるのか!',
59 ],
60 shell: [
61 'おーっと、ここで {tool} を繰り出した!',
62 '{tool} で一気に勝負に出た!',
63 'コマンド一閃!{tool} が火を噴く!',
64 '強烈な {tool}!ターミナルが唸りを上げる!',
65 'ここで {tool} を叩き込む!大胆な攻めだ!',
66 ],
67 edit: [
68 '鮮やかなパスで {file} を書き換える!',
69 '{file} にピンポイントの修正!見事なコントロール!',
70 'ここで {file} に切り込んだ!一気に書き換える!',
71 '{file} の守りを崩した!華麗な {tool}!',
72 '狙いすました一撃!{file} を修正!',
73 ],
74 write: [
75 '{file} に新しいファイルを叩き込んだ!',
76 'ゼロから {file} を書き上げる!これは大技だ!',
77 '一気呵成に {file} を書き下ろす!',
78 '{file} が誕生!新たな布陣だ!',
79 'ここで {file} を丸ごと書いた!思い切ったプレー!',
80 ],
81 read: [
82 'じっくりと資料を読み込んでいる',
83 '{file} に目を通す!相手の手の内を探っているぞ!',
84 'ここは {file} を読み込んで様子見だ!',
85 '冷静に {file} を分析!次の一手への布石か!',
86 '{file} を読む!スカウティングに余念がない!',
87 ],
88 search: [
89 'フィールドの隅々まで探し回る!',
90 '{tool} で手がかりを追う!執念の捜索!',
91 'どこだ、どこにある!{tool} が走る!',
92 'ここで {tool}!獲物を探すハンターの目だ!',
93 'コードの森をかき分けて {tool}!',
94 ],
95 web: [
96 'ここで場外へ!{tool} で外の情報を取りに行く!',
97 'ネットの海へ飛び込んだ!{tool}!',
98 '{tool} で世界中から情報をかき集める!',
99 'スタジアムの外まで手を伸ばす {tool}!',
100 '最新の情報を求めて {tool}!',
101 ],
102 agent: [
103 'ここで助っ人登場!サブエージェントを投入!',
104 '選手交代!頼れる仲間をフィールドへ送り出す!',
105 '{tool} で援軍を呼んだ!総力戦だ!',
106 'ベンチから新戦力!これは大きな采配だ!',
107 '仲間に託した!チームプレーで攻める!',
108 ],
109 other: [
110 '{tool} を繰り出した!これは珍しい技だ!',
111 'ここで {tool}!意表を突くプレー!',
112 '{tool} で揺さぶりをかける!',
113 'おーっと {tool} だ!変幻自在!',
114 '秘密兵器 {tool} を投入!',
115 ],
116 error: [
117 'ああーっと!ここでまさかのエラー!',
118 'なんということだ!{tool} が失敗!',
119 'これは痛い!{tool} でつまずいた!',
120 'まさかの展開!{tool} がエラーを返した!',
121 'ああっと、ここでミス!場内がどよめく!',
122 ],
123 streak: [
124 'これで {n} 連続成功、止まらない!',
125 '{n} 連続成功!手がつけられない!',
126 '怒涛の {n} 連続成功!',
127 'まだ続く!{n} 連続成功だ!',
128 '{n} 連続!完全に流れを掴んだ!',
129 ],
130 comeback: [
131 '見事に立て直した!さすがの修正力!',
132 'エラーから即座に反撃!これぞ王者の貫禄!',
133 '転んでもただでは起きない!見事なリカバリー!',
134 '立ち上がった!エラーを乗り越えて前へ進む!',
135 '不屈の闘志!ここで見事に持ち直した!',
136 ],
137 ten: [
138 '{n} 連続成功!これはもう止められない!',
139 'ついに {n} 連続!歴史に残る快進撃だ!',
140 '大台の {n} 連続成功!スタジアム総立ち!',
141 '{n} 連続成功達成!神がかっている!',
142 'パーフェクトゲームの予感!{n} 連続成功!',
143 ],
144 end: [
145 '試合終了!見事な {time}でした!',
146 'ここで試合終了のホイッスル!{time}の熱戦!',
147 'ゲームセット!{time}、見事な戦いぶりでした!',
148 'やりきった!{time}の激闘に拍手を!',
149 'フィニッシュ!{time}で決着です!',
150 ],
151 abort: [
152 'ここで試合中断!一体何があったのか!',
153 'おっと、中断です!作戦タイムか!',
154 '試合が止められた!ベンチの指示だ!',
155 'ここでストップ!{time}で一旦終了!',
156 'まさかの打ち切り!仕切り直しだ!',
157 ],
158 fail: [
159 'ああっと、ここで試合が止まった!エラーで幕切れだ!',
160 'なんということだ!{time}の攻防はエラーで終わった!',
161 '無念!決着つかずのまま終了!',
162 'まさかの幕切れ!この試合、ノーゲームです!',
163 'ここで打ち止め!次の試合で雪辱を!',
164 ],
165 long: [
166 '{time}の長丁場、ついに決着!',
167 '大熱戦に終止符!{time}の死闘でした!',
168 '延長戦を制した!{time}の長い戦いが終わる!',
169 '{time}に及ぶ激闘、ここに決着!',
170 '長かった!{time}の大一番、決着です!',
171 ],
172 },
173 冷静: {
174 start: [
175 'ターンが始まりました。',
176 '試合開始です。落ち着いた立ち上がり。',
177 'さて、始まりました。まずは様子を見ましょう。',
178 '開始です。どう組み立ててくるか注目です。',
179 '新しいターンに入りました。',
180 ],
181 shell: [
182 '{tool} を実行しています。',
183 'ここは {tool} を選びましたね。',
184 '{tool} でコマンドを走らせます。',
185 '手堅く {tool} を使ってきました。',
186 '{tool} の出番です。',
187 ],
188 edit: [
189 '{file} を修正しています。',
190 '{file} の一部を書き換えています。',
191 '{file} に手を入れます。的確ですね。',
192 '必要な所だけ {file} を直しています。',
193 '{file} を丁寧に編集中です。',
194 ],
195 write: [
196 '{file} を新しく書いています。',
197 '{file} を作成しています。',
198 '{file} を一から書き起こします。',
199 'ここで {file} を用意しました。',
200 '{file} を書き出しています。',
201 ],
202 read: [
203 '資料を読み込んでいます。',
204 '{file} を読んでいます。',
205 '{file} を確認中。慎重ですね。',
206 'まずは {file} から情報を集めます。',
207 '{file} に目を通しています。',
208 ],
209 search: [
210 '{tool} で探しています。',
211 '手がかりを {tool} で絞り込みます。',
212 '{tool} を使って該当箇所を探しています。',
213 '検索で全体を見渡しています。',
214 '{tool} で対象を洗い出しています。',
215 ],
216 web: [
217 '{tool} で外部の情報を確認しています。',
218 'ウェブから資料を取り寄せます。',
219 '{tool} を使いました。最新の情報が要るようです。',
220 '外の情報を {tool} で参照中です。',
221 '{tool} で調べものです。',
222 ],
223 agent: [
224 'サブエージェントに作業を任せました。',
225 '{tool} で分担に入ります。',
226 '別の担当を立てましたね。合理的です。',
227 '作業を切り分けて委ねています。',
228 '{tool} を呼びました。並行して進めるようです。',
229 ],
230 other: [
231 '{tool} を使っています。',
232 'ここで {tool} です。',
233 '{tool} を呼び出しました。',
234 '{tool} の結果を待ちます。',
235 '少し珍しい {tool} ですね。',
236 ],
237 error: [
238 'エラーが出ました。',
239 '{tool} が失敗しました。立て直しが必要です。',
240 'ここでエラーです。原因を見たいところ。',
241 '{tool} はうまくいきませんでした。',
242 '失敗が返ってきました。次の対応に注目です。',
243 ],
244 streak: [
245 '{n} 回続けて成功しています。',
246 '{n} 連続成功。安定しています。',
247 'これで {n} 回連続です。',
248 '{n} 連続成功。順調な運びです。',
249 '{n} 回ミスなく進んでいます。',
250 ],
251 comeback: [
252 'エラーから立て直しました。',
253 '失敗の後、きちんと持ち直しました。',
254 '修正が効きました。順調に戻っています。',
255 '落ち着いて立て直しましたね。',
256 'エラーを踏まえて成功させました。',
257 ],
258 ten: [
259 '{n} 連続成功です。見事な安定感です。',
260 '{n} 回連続でミスがありません。',
261 '{n} 連続成功に到達しました。',
262 'これで {n} 連続。非常に安定しています。',
263 '{n} 連続成功。崩れる気配がありません。',
264 ],
265 end: [
266 'ターン終了。所要 {time}でした。',
267 '試合終了です。{time}でした。',
268 '終わりました。{time}、手堅い内容でした。',
269 'ここで終了。{time}です。',
270 '{time}で一区切りです。',
271 ],
272 abort: [
273 '中断されました。',
274 'ここで止まりました。{time}でした。',
275 '途中で打ち切りです。',
276 '中断です。仕切り直しでしょう。',
277 '{time}で止められました。',
278 ],
279 fail: [
280 'エラーでターンが終わりました。',
281 '{time}でしたが、決着はつきませんでした。',
282 'ここで終了です。結果はエラーでした。',
283 '答えが出ないまま終わりました。',
284 'エラーで止まりました。立て直しが必要です。',
285 ],
286 long: [
287 '{time}の長いターンが終わりました。',
288 '{time}かかりましたが、完了です。',
289 '長丁場でした。{time}で決着です。',
290 '{time}の作業、お疲れさまでした。',
291 'ようやく終了です。{time}でした。',
292 ],
293 },
294 関西: {
295 start: [
296 'さあ始まったでー!',
297 'ほな、いっちょやったろか!',
298 'きたきた、試合開始や!',
299 'よっしゃ、始めるでー!',
300 'さあさあ、お立ち会い!始まるで!',
301 ],
302 shell: [
303 'おっ、ここで {tool} かいな!',
304 '{tool} ぶちかましたでー!',
305 'ほれ出た、{tool} や!',
306 '{tool} でガツンといったれ!',
307 'ここは {tool} でいくんかい!',
308 ],
309 edit: [
310 '{file} をちょちょいと書き換えよった!',
311 'ええパスや!{file} をいじっとる!',
312 '{file} に手ぇ入れたで!',
313 'うまいこと {file} 直しよるなあ!',
314 '{file} をシュッと修正や!',
315 ],
316 write: [
317 '{file} を新しくこしらえたで!',
318 'いきなり {file} 書いてもうた!',
319 '{file} を一から作っとるがな!',
320 'おお、{file} が生まれたで!',
321 '{file} をドーンと書いたった!',
322 ],
323 read: [
324 'じーっと資料読んどるなあ',
325 '{file} を読み込んどるで',
326 '{file} とにらめっこや',
327 'まずは {file} 読んで作戦会議やな',
328 '{file} を穴が開くほど見とる!',
329 ],
330 search: [
331 'どこや、どこにあるんや!{tool} で探しとる!',
332 '{tool} で草の根分けて探しとるで!',
333 'あっちこっち探し回っとるがな!',
334 '{tool} で宝探しや!',
335 '探し物はなんですかー、{tool} やで!',
336 ],
337 web: [
338 '{tool} でネットまで出張や!',
339 '外の情報取りに行きよった!{tool}!',
340 '{tool} でちょっと調べもんや!',
341 'ネットの海に飛び込んだで!',
342 '{tool} で世間様に聞いとる!',
343 ],
344 agent: [
345 '助っ人呼んできよった!',
346 '{tool} で応援頼んだで!',
347 'ひとりで抱えへん、ええ判断や!',
348 '仲間にパス出したで!',
349 '選手交代や!頼んだでー!',
350 ],
351 other: [
352 '{tool} やて?渋いとこ突くなあ!',
353 'ここで {tool} かいな!',
354 '{tool} 出してきよった!',
355 'おもろい手ぇ使うやん、{tool}!',
356 '{tool} でひと工夫や!',
357 ],
358 error: [
359 'あかーん!ここでまさかのエラーや!',
360 'なんでやねん!{tool} がこけたで!',
361 'ああっ、{tool} しくじってもうた!',
362 'ちょ、待って!エラー出てるやん!',
363 'あちゃー、やってもうた!',
364 ],
365 streak: [
366 '{n} 連続成功や、止まらへんで!',
367 '{n} 連続!ノリノリやな!',
368 'もう {n} 連続やで!どないなっとんねん!',
369 '{n} 連続成功!絶好調や!',
370 'まだまだいくで!{n} 連続!',
371 ],
372 comeback: [
373 'よう立て直した!さすがやで!',
374 '転んでもタダでは起きへん!',
375 'エラーなんかなんのその!',
376 '持ち直したで!やるやん!',
377 'ほら見てみ、ちゃんと取り返したやろ!',
378 ],
379 ten: [
380 '{n} 連続成功や!もう誰にも止められへん!',
381 '{n} 連続!神がかっとるで!',
382 'うそやろ、{n} 連続成功や!',
383 '{n} 連続成功!拍手喝采や!',
384 '{n} 連続やて!今日は冴えとるなあ!',
385 ],
386 end: [
387 '試合終了や!{time}、ようやった!',
388 'しまいや!{time}でキッチリ決めたで!',
389 'おつかれさん!{time}の熱戦やったな!',
390 'はい終わり!{time}、上出来や!',
391 'ゲームセット!{time}で決着や!',
392 ],
393 abort: [
394 'ちょ、止められてもうた!',
395 'ここで中断かいな!',
396 'あらら、打ち切りや!',
397 '{time}でストップや!',
398 'いったん休憩やな!',
399 ],
400 fail: [
401 'あかん、エラーで終わってもうた!',
402 '{time}粘ったのに、エラーで幕切れや!',
403 'なんでやねん!決着つかへんかった!',
404 'ここで終わりかいな!エラーやで!',
405 'しゃあない、次で取り返そ!',
406 ],
407 long: [
408 '{time}も粘ったで!ようやく決着や!',
409 '長かったなあ!{time}の大勝負や!',
410 '{time}の大熱戦、お疲れさん!',
411 'やっと終わったで!{time}もかかったがな!',
412 '{time}の長丁場、見事に決着や!',
413 ],
414 },
415}
416
417export const toStyle = (v: unknown): Style => (STYLES.includes(v as Style) ? (v as Style) : '熱血')
418
419const SHELL = new Set(['Bash', 'PowerShell'])
420const EDIT = new Set(['Edit', 'MultiEdit', 'NotebookEdit'])
421const SEARCH = new Set(['Grep', 'Glob', 'LS', 'ToolSearch'])
422const WEB = new Set(['WebFetch', 'WebSearch'])
423const AGENT = new Set(['Agent', 'Task'])
424
425/** ツールの名前から場面を決める */
426export function sceneOf(tool: string): Scene {
427 if (SHELL.has(tool)) return 'shell'
428 if (EDIT.has(tool)) return 'edit'
429 if (tool === 'Write') return 'write'
430 if (tool === 'Read') return 'read'
431 if (SEARCH.has(tool)) return 'search'
432 if (WEB.has(tool)) return 'web'
433 if (AGENT.has(tool)) return 'agent'
434 return 'other'
435}
436
437/** 制御文字を落とす (Text の子や SVG に入るとペインが閉じる)。タブと改行は空白に */
438export const clean = (s: string) => s.replace(/[\t\n\r]/g, ' ').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '')
439
440/** ツールの入力から、触っているファイルの名前 (フォルダは落とす)。無ければ「ファイル」 */
441export function fileOf(input: Record<string, unknown>): string {
442 for (const k of ['file_path', 'notebook_path', 'path']) {
443 const v = input[k]
444 if (typeof v === 'string' && v.trim() !== '') return clean(v.split(/[\\/]/).filter(Boolean).pop() ?? v)
445 }
446 return 'ファイル'
447}
448
449/** 42 秒 / 3 分 12 秒 / 5 分 */
450export function duration(ms: number): string {
451 const s = Math.max(0, Math.round(ms / 1000))
452 if (s < 60) return `${s} 秒`
453 const m = Math.floor(s / 60)
454 return s % 60 === 0 ? `${m} 分` : `${m} 分 ${s % 60} 秒`
455}
456
457/** 0〜len-1 を選ぶ。last と同じにはしない (len が 2 以上の時) */
458export function pickIndex(len: number, last: number, rng: () => number): number {
459 if (len <= 1) return 0
460 const i = Math.min(len - 1, Math.floor(rng() * len))
461 if (i !== last) return i
462 return (i + 1 + Math.min(len - 2, Math.floor(rng() * (len - 1)))) % len
463}
464
465export const fill = (template: string, vars: Readonly<Record<string, string | number>>) =>
466 template.replace(/\{(\w+)\}/g, (all, k: string) => (k in vars ? String(vars[k]) : all))
467hooks/svg.ts 160 lines1// 実況席の絵。数字が毎秒変わる板と、流れ続けるテロップと、照明の絵は別々の Svg にする
2// (source が変わるとアニメが頭からやり直しになるため)
3import type { ThemeKey } from 'claude-code'
4
5import type { LiveCommentaryClock, LiveCommentaryKind, LiveCommentaryLine, LiveCommentaryScore } from '../types'
6import { duration } from './phrases'
7import { C, DARK, HEADER_H, W, countUp, esc, frame, header, panel } from './style'
8
9/** 実況の種類の色 (SVG 用。今のテーマの色を呼ぶたびに読む) */
10export const kindColor = (kind: LiveCommentaryKind) => ({ play: C.text, error: C.red, cheer: C.yellow, whistle: C.blue })[kind]
11
12/** 実況の種類の色 (Text 用。テーマの名前なので明るいテーマにも付いていく) */
13export const KIND_THEME: Record<LiveCommentaryKind, ThemeKey | undefined> = { play: undefined, error: 'error', cheer: 'warning', whistle: 'suggestion' }
14
15/** 見出しの動きを抜く。何度も描き直す絵で、下線が描き直しのたびに伸び直さないように */
16const still = (s: string) => s.replace(/<animate\b[^>]*\/>/g, '')
17
18/** 文字幅のおおよそ (全角 = size、半角 = 0.6 size) */
19const textWidth = (s: string, size: number) => [...s].reduce((w, c) => w + ((c.codePointAt(0) ?? 0) > 0xff ? size : size * 0.6), 0)
20
21const svg = (h: number, body: string[]) =>
22 [`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${h}" font-family="sans-serif">`, ...body, '</svg>'].join('')
23
24// 夜のスタジアムは情景なので、テーマによらず暗い色のまま (明るいテーマでも夜空の上の字は明るく)
25const LAMP_OFF = '#3a3f47'
26const LAMP = '#fff8dc'
27const SKY_TOP = '#05070b'
28const SKY_ON = '#132238'
29const GRASS_ON = '#1a7f37'
30const GRASS_OFF = '#1c2a20'
31const BOX = '#21262d'
32
33/** 照明塔 1 本 (x は塔の中心)。オンなら光の筋がゆらぐ */
34function tower(x: number, dir: 1 | -1, isOn: boolean, delay: number): string {
35 const lamps: string[] = []
36 for (let r = 0; r < 2; r++)
37 for (let c = 0; c < 4; c++) {
38 const lx = x - 15 + c * 10
39 const ly = 14 + r * 9
40 lamps.push(
41 `<circle cx="${lx}" cy="${ly}" r="3.4" fill="${isOn ? LAMP : LAMP_OFF}">${
42 isOn ? `<animate attributeName="opacity" values="1;0.75;1" dur="${(1.6 + c * 0.3).toFixed(1)}s" begin="${delay}s" repeatCount="indefinite"/>` : ''
43 }</circle>`,
44 )
45 }
46 const beam = isOn
47 ? `<polygon points="${x - 18},20 ${x + 18},20 ${x + dir * 150},110 ${x + dir * 30},110" fill="url(#beam)" opacity="0.3"><animate attributeName="opacity" values="0.22;0.4;0.22" dur="3s" begin="${delay}s" repeatCount="indefinite"/></polygon>`
48 : ''
49 return [
50 beam,
51 `<rect x="${x - 2}" y="28" width="4" height="62" fill="${DARK.line}"/>`,
52 `<rect x="${x - 21}" y="7" width="42" height="24" rx="3" fill="${BOX}" stroke="${DARK.line}"/>`,
53 ...lamps,
54 ].join('')
55}
56
57/** スタジアムの絵 (オン/オフと口調でしか変わらない) */
58export function stadiumSvg(isOn: boolean, style: string): string {
59 const H = 132
60 const crowd: string[] = []
61 const colors = [DARK.blue, DARK.green, DARK.yellow, DARK.red, DARK.purple, DARK.orange]
62 for (let i = 0; i < 30; i++) {
63 const cx = 10 + i * 12.4
64 const cy = 98 + (i % 2) * 6
65 const color = colors[(i * 7) % colors.length]
66 const wave = isOn
67 ? `<animate attributeName="cy" values="${cy};${cy - 5};${cy}" dur="1.8s" begin="${((i * 0.06) % 1.8).toFixed(2)}s" repeatCount="indefinite"/>`
68 : ''
69 crowd.push(`<circle cx="${cx.toFixed(1)}" cy="${cy}" r="4.2" fill="${color}" opacity="${isOn ? 0.85 : 0.35}">${wave}</circle>`)
70 }
71 return svg(H, [
72 `<defs>`,
73 `<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${SKY_TOP}"/><stop offset="1" stop-color="${isOn ? SKY_ON : DARK.bg}"/></linearGradient>`,
74 `<linearGradient id="beam" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${LAMP}" stop-opacity="0.9"/><stop offset="1" stop-color="${LAMP}" stop-opacity="0"/></linearGradient>`,
75 `</defs>`,
76 `<rect width="${W}" height="${H}" rx="10" fill="url(#sky)"/>`,
77 tower(46, 1, isOn, 0),
78 tower(W - 46, -1, isOn, 1.2),
79 `<rect x="0" y="106" width="${W}" height="26" fill="${isOn ? GRASS_ON : GRASS_OFF}"/>`,
80 `<line x1="${W / 2}" y1="106" x2="${W / 2}" y2="132" stroke="#e6f6ff" stroke-opacity="0.4"/>`,
81 ...crowd,
82 `<text x="${W / 2}" y="46" font-size="22" font-weight="bold" fill="${DARK.text}" text-anchor="middle">実況席</text>`,
83 // 札は地の部品なので今のテーマの色
84 `<rect x="${W / 2 - 46}" y="56" width="92" height="22" rx="11" fill="${C.face}" stroke="${isOn ? C.red : C.line}"/>`,
85 isOn
86 ? `<circle cx="${W / 2 - 32}" cy="67" r="4" fill="${C.red}"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite"/></circle>`
87 : '',
88 `<text x="${W / 2 + 6}" y="72" font-size="12" fill="${isOn ? C.text : C.dim}" text-anchor="middle">${isOn ? `${esc(style)} 実況中` : '実況オフ'}</text>`,
89 ])
90}
91
92const mmss = (ms: number) => {
93 const s = Math.max(0, Math.floor(ms / 1000))
94 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
95}
96
97/** 時計の文: 試合中なら経過、終わっていれば前の試合の長さ */
98export const clockLabel = (c: LiveCommentaryClock) =>
99 c.startedAt > 0 ? `試合時間 ${mmss(c.now - c.startedAt)}` : c.lastMs > 0 ? `前の試合 ${duration(c.lastMs)}` : '試合前'
100
101/**
102 * スコアボード。試合中は時計で毎秒描き直すので動きは無し。
103 * 試合の外 (試合前・前の試合) は描き直さないので、数字が 0 から増える
104 */
105export function boardSvg(score: LiveCommentaryScore, clock: LiveCommentaryClock): string {
106 const isLive = clock.startedAt > 0
107 const top = HEADER_H + 10
108 const H = top + 56 + 12
109 const cells: [string, number, string][] = [
110 ['成功', score.ok, C.green],
111 ['エラー', score.err, C.red],
112 ['連続', score.streak, C.yellow],
113 ]
114 const cw = (W - 32) / 3
115 const body = cells.flatMap(([label, n, color], i) => {
116 const cx = 16 + cw * i + cw / 2
117 const attrs = `x="${cx}" y="${top + 46}" font-size="24" font-weight="bold" fill="${color}" text-anchor="middle" font-family="monospace"`
118 return [
119 panel(16 + cw * i + 4, top, cw - 8, 56),
120 `<text x="${cx}" y="${top + 18}" font-size="12" fill="${C.dim}" text-anchor="middle">${label}</text>`,
121 isLive ? `<text ${attrs}>${n}</text>` : countUp(n, attrs, String, 0.2 + i * 0.1),
122 ]
123 })
124 return frame(H, still(header('スコア', clockLabel(clock), isLive ? C.yellow : C.dim)) + body.join(''))
125}
126
127/** 最新の実況を大きく、テロップのように右から左へ流す */
128export function tickerSvg(line: LiveCommentaryLine | null, isOn: boolean): string {
129 const band = HEADER_H + 8
130 const H = band + 42 + 8
131 const text = !isOn ? '実況はお休み中です' : line?.text ?? 'まもなく試合開始です'
132 const color = !isOn ? C.dim : kindColor(line?.kind ?? 'play')
133 const size = 20
134 const tw = Math.ceil(textWidth(text, size))
135 // 左端で少し止めて読ませ、左へ流れて消え、右から戻ってくる
136 const x0 = 16
137 const speed = 70
138 const hold = 1.6
139 const out = (x0 + tw) / speed
140 const back = (W - x0) / speed
141 const total = hold + out + back
142 const k1 = (hold / total).toFixed(3)
143 const k2 = ((hold + out) / total).toFixed(3)
144 // keyTimes に同じ値を並べるとアニメごと無視されるので、右端へは 0.001 の間で跳ぶ
145 const k3 = ((hold + out) / total + 0.001).toFixed(3)
146 return frame(
147 H,
148 [
149 `<defs><clipPath id="band"><rect x="0" y="${band}" width="${W}" height="42"/></clipPath></defs>`,
150 // 実況のたびに描き直すので、見出しは動かさない
151 still(header('最新の実況', isOn ? 'LIVE' : 'お休み中', isOn ? C.red : C.dim)),
152 `<rect x="0" y="${band}" width="${W}" height="42" fill="${C.face}"/>`,
153 `<rect x="0" y="${band}" width="${W}" height="2" fill="${color}" opacity="0.6"/>`,
154 `<g clip-path="url(#band)"><text x="0" y="${band + 28}" font-size="${size}" font-weight="bold" fill="${color}" transform="translate(${x0} 0)">${esc(text)}`,
155 `<animateTransform attributeName="transform" type="translate" values="${x0} 0;${x0} 0;${-tw} 0;${W} 0;${x0} 0" keyTimes="0;${k1};${k2};${k3};1" dur="${total.toFixed(1)}s" repeatCount="indefinite"/>`,
156 `</text></g>`,
157 ].join(''),
158 )
159}
160hooks/style.ts 315 lines1// 全 mod で共通の見た目(色・見出し・動き)と小物。
2// 正本は shared/style.ts。各 mod の hooks/style.ts はその写しなので、直すのは正本だけにして
3// `node tools/copy_shared.mts` で写す(`--check` で写しのずれを見つける)。
4import type { EngineInterface, On } from 'claude-code'
5
6export type Palette = {
7 bg: string
8 face: string
9 line: string
10 text: string
11 dim: string
12 weak: string
13 blue: string
14 green: string
15 yellow: string
16 red: string
17 purple: string
18 orange: string
19}
20
21/** GitHub Dark 系(docs/mod-guide.md の 7 節) */
22export const DARK: Palette = {
23 bg: '#0d1117',
24 face: '#161b22',
25 line: '#30363d',
26 text: '#c9d1d9',
27 dim: '#8b949e',
28 weak: '#8b949e',
29 blue: '#58a6ff',
30 green: '#3fb950',
31 yellow: '#d29922',
32 red: '#f85149',
33 purple: '#bc8cff',
34 orange: '#f78166',
35}
36
37/** GitHub Light 系 */
38export const LIGHT: Palette = {
39 bg: '#ffffff',
40 face: '#f6f8fa',
41 line: '#d0d7de',
42 text: '#1f2328',
43 dim: '#656d76',
44 weak: '#656d76',
45 blue: '#0969da',
46 green: '#1a7f37',
47 yellow: '#9a6700',
48 red: '#cf222e',
49 purple: '#8250df',
50 orange: '#bc4c00',
51}
52
53// ponytail: 今の色はモジュールの変数 1 個(この mod の中だけ)。SVG を作る関数は C.bg などを呼ぶたびに読むので、
54// 引数を増やさずにテーマが変わる。描く途中で切り替わることは無い(JS は 1 本で走る)
55/** 今の色。setTheme で中身が入れ替わる(同じ物を指し続けるので、import した C のままでよい) */
56export const C: Palette = { ...DARK }
57let isLightNow = false
58
59export const isLight = () => isLightNow
60
61/** /config の theme の値が明るい系か('light'・'light-daltonized'・'light-ansi' など) */
62export const isLightTheme = (value: unknown) => typeof value === 'string' && value.startsWith('light')
63
64export function setTheme(light: boolean) {
65 isLightNow = light
66 Object.assign(C, light ? LIGHT : DARK)
67}
68
69/**
70 * 今の /config の theme を読んで色を合わせる。mod 自身の session.start の中で呼ぶ
71 * (同じイベントに matcher 無しのフックを 2 つ置くと読み込みを拒まれるので、ここでは on しない)。
72 * VS Code の配色は mod から読めないので、Claude Code の theme が light 系の時だけ明るくなる
73 */
74export async function readTheme($: EngineInterface) {
75 const rows = await $.config.list().catch(() => [])
76 setTheme(isLightTheme(rows.find(r => r.key === 'theme')?.value))
77}
78
79/** /config で theme が変わったら色を替えて描き直す。register の中で 1 回呼ぶ */
80export function followTheme(on: On) {
81 on('config.set', { key: 'theme' }, async ($, e, next) => {
82 const r = await next(e)
83 if (r.deny === undefined) {
84 setTheme(isLightTheme(r.value))
85 $.ui.invalidate('ui.render')
86 }
87 return r
88 })
89}
90
91// ---- 小物 ----
92
93/** 外から来る文字列の制御文字を落とす。エンジンは制御文字を含む Text の子や alt を拒み、ペインが閉じる */
94export const clean = (s: string) => s.replace(/[\t\n\r]+/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim()
95
96/** SVG の文字に入れる前のエスケープ(制御文字も落とす) */
97export const esc = (s: string) =>
98 clean(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
99
100// ---- 時間帯と季節 (日本時間 JST = UTC+9 固定。動いている環境のタイムゾーンには頼らない) ----
101
102export type DayPart = '朝' | '昼' | '夕' | '夜'
103export type Season = '春' | '夏' | '秋' | '冬'
104
105const jstDate = (now: number) => new Date(now + 9 * 3_600_000)
106
107/** 時間帯。朝 5-10 時 / 昼 10-16 時 / 夕 16-19 時 / 夜 19-5 時 */
108export const dayPart = (now: number): DayPart => {
109 const h = jstDate(now).getUTCHours()
110 return h >= 5 && h < 10 ? '朝' : h >= 10 && h < 16 ? '昼' : h >= 16 && h < 19 ? '夕' : '夜'
111}
112
113/** 季節。春 3-5 月 / 夏 6-8 月 / 秋 9-11 月 / 冬 12-2 月 */
114export const season = (now: number): Season => {
115 const m = jstDate(now).getUTCMonth() + 1
116 return m >= 3 && m <= 5 ? '春' : m >= 6 && m <= 8 ? '夏' : m >= 9 && m <= 11 ? '秋' : '冬'
117}
118
119/** n 文字を超えたら … で切る */
120export const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(0, n - 1))}…` : s)
121
122// ---- 記録を守る(docs/mod-guide.md の 9 節) ----
123
124/** JST の YYYY-MM-DD */
125export const jstDay = (now: number) => jstDate(now).toISOString().slice(0, 10)
126
127export type Backup = { day: string; value: unknown }
128
129// ponytail: $.fs に消す手が無いので、曜日ごとの 7 つの枠を上書きして 7 日分だけ残す。もっと残すなら枠を日付にして process で消す
130const backupPath = (dir: string, plugin: string, key: string, wd: number) => `${dir.replace(/[\\/]+$/, '')}/${plugin}/${key}-${wd}.json`
131
132/**
133 * $.store の key の値を、その日はじめて呼ばれた時だけ dir に控える(曜日ごとの枠で 7 日分)。
134 * dir が空なら何もしない(公開の既定は空。入れた人が backupDir を設定する)。書いたら true
135 */
136export async function backupDaily($: EngineInterface, dir: string, key: string, value: unknown, now: number): Promise<boolean> {
137 if (!dir.trim() || value === undefined) return false
138 const day = jstDay(now)
139 const path = backupPath(dir, $.plugin.name, key, jstDate(now).getUTCDay())
140 // 壊れた枠(途中まで・null)は無いのと同じに扱って上書きする
141 const old = await $.fs.read(path).then(t => parseBackup(t)?.day ?? '', () => '')
142 if (old === day) return false
143 await $.fs.write(path, JSON.stringify({ day, value } satisfies Backup))
144 return true
145}
146
147/** 控えの 1 枠を読む。壊れていれば undefined */
148export function parseBackup(text: string): Backup | undefined {
149 try {
150 const b = JSON.parse(text) as Partial<Backup> | null
151 return b && typeof b.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(b.day) && b.value !== undefined ? (b as Backup) : undefined
152 } catch {
153 return undefined
154 }
155}
156
157/** 控えの一覧。新しい日から順。読めない枠・壊れた枠は飛ばす */
158export async function listBackups($: EngineInterface, dir: string, key: string): Promise<Backup[]> {
159 if (!dir.trim()) return []
160 const all = await Promise.all(
161 [0, 1, 2, 3, 4, 5, 6].map(wd =>
162 $.fs.read(backupPath(dir, $.plugin.name, key, wd)).then(parseBackup, () => undefined),
163 ),
164 )
165 return all.filter((b): b is Backup => b !== undefined).sort((a, b) => b.day.localeCompare(a.day))
166}
167
168let loadedPrint: string | undefined
169let staleAt = 0
170let isStaleNow = false
171let toldStale = false
172
173const normPath = (p: string) => p.replace(/[\\/]+/g, '/').replace(/\/$/, '').toLowerCase()
174
175/** FNV-1a 32bit。中身が変わったかを見るだけ */
176function fnv(s: string) {
177 let h = 0x811c9dc5
178 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
179 return (h >>> 0).toString(16)
180}
181
182/**
183 * 今ディスクにある、この mod の指紋。plugin.json と hooks の .ts / .tsx の中身から作る
184 * (版の数字を上げずに中身だけ変えた時も変わる)。plugin.json が読めなければ undefined
185 */
186async function diskPrint($: EngineInterface): Promise<string | undefined> {
187 const root = $.plugin.root
188 let manifest: string | undefined
189 for (const p of [`${root}/.claude-plugin/plugin.json`, `${root}/plugin.json`]) {
190 manifest = await $.fs.read(p).then(t => t, () => undefined)
191 if (manifest !== undefined) break
192 }
193 if (manifest === undefined) return undefined
194 const names = await $.fs.list(`${root}/hooks`).then(
195 es => es.filter(e => e.kind === 'file' && /\.tsx?$/.test(e.name)).map(e => e.name).sort(),
196 () => [] as string[],
197 )
198 const parts = await Promise.all(names.map(n => $.fs.read(`${root}/hooks/${n}`).then(t => `${n}\n${t}`, () => n)))
199 return fnv([manifest, ...parts].join('\0'))
200}
201
202/**
203 * マーケットプレイスから入れた形(<plugins>/cache/<market>/<name>/<版>)なら、installed_plugins.json に
204 * 今入っている場所が自分と違うか。違えば新しい版が入っている。分からなければ false
205 */
206async function replacedInCache($: EngineInterface): Promise<boolean> {
207 const root = normPath($.plugin.root)
208 const at = root.lastIndexOf('/plugins/cache/')
209 if (at < 0) return false
210 const raw = await $.fs.read(`${$.plugin.root.slice(0, at + '/plugins'.length)}/installed_plugins.json`).then(t => t, () => '')
211 try {
212 const plugins = (JSON.parse(raw) as { plugins?: Record<string, { installPath?: unknown }[]> }).plugins ?? {}
213 const mine = Object.entries(plugins)
214 .filter(([k]) => k.startsWith(`${$.plugin.name}@`))
215 .flatMap(([, v]) => (Array.isArray(v) ? v : []))
216 .map(e => (typeof e.installPath === 'string' ? normPath(e.installPath) : ''))
217 return mine.length > 0 && !mine.includes(root)
218 } catch {
219 return false
220 }
221}
222
223/** 読み込んだ時の指紋を覚える。mod の session.start で 1 回呼ぶ */
224export async function rememberVersion($: EngineInterface) {
225 loadedPrint ??= await diskPrint($)
226}
227
228/**
229 * 新しい版が入ったのに、このセッションはまだ古い版で動いているか。
230 * フォルダから読ませている時は中身の指紋が読み込んだ時と違えば、マーケットプレイスから入れた時は
231 * 入っている場所が自分でなくなれば true。読み直すのは 60 秒に 1 回まで。分からない時は false
232 */
233export async function isStale($: EngineInterface, now: number): Promise<boolean> {
234 if (isStaleNow || now - staleAt < 60_000) return isStaleNow
235 staleAt = now
236 if (await replacedInCache($)) return (isStaleNow = true)
237 if (loadedPrint === undefined) return false
238 const p = await diskPrint($)
239 isStaleNow = p !== undefined && p !== loadedPrint
240 return isStaleNow
241}
242
243/**
244 * $.store に書いてよいか。古い版のままなら書かず、1 回だけトーストで開き直しを頼む
245 * (古い版は新しい版の欄を知らないので、書くと消してしまう)
246 */
247export async function mayWrite($: EngineInterface, now: number): Promise<boolean> {
248 if (!(await isStale($, now))) return true
249 if (!toldStale) {
250 toldStale = true
251 $.ui.toast(`${$.plugin.name}: 新しい版が入りました。VS Code を開き直すまで記録は書きません`)
252 }
253 return false
254}
255
256// ---- SVG の部品 ----
257
258export const W = 380
259export const HEADER_H = 44
260
261/** SVG 全体の枠。地を塗って body を入れる。幅は W にそろえる */
262export const frame = (h: number, body: string, w = W) =>
263 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" font-family="sans-serif">` +
264 `<rect width="${w}" height="${h}" rx="10" fill="${C.bg}"/>${body}</svg>`
265
266/** 面(カード)。角丸 8、線 1 */
267export const panel = (x: number, y: number, w: number, h: number, fill = C.face) =>
268 `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="${fill}" stroke="${C.line}"/>`
269
270/**
271 * 共通の見出し帯(高さ HEADER_H)。左に色の縦線、左に題、右に添え書き。
272 * 下の細い線が左から伸びる
273 */
274export function header(title: string, sub = '', accent = C.blue, y = 0, w = W) {
275 const pad = 14
276 return [
277 `<rect x="0" y="${y}" width="${w}" height="${HEADER_H}" rx="10" fill="${C.face}"/>`,
278 `<rect x="0" y="${y + 8}" width="4" height="${HEADER_H - 16}" rx="2" fill="${accent}"/>`,
279 `<text x="${pad}" y="${y + 28}" font-size="16" font-weight="bold" fill="${C.text}">${esc(title)}</text>`,
280 sub === '' ? '' : `<text x="${w - pad}" y="${y + 28}" font-size="12" fill="${C.dim}" text-anchor="end">${esc(sub)}</text>`,
281 growBar(pad, y + HEADER_H - 3, w - pad * 2, 2, accent, 0, 0.35),
282 ].join('')
283}
284
285/** 横に伸びる棒。幅 0 から w まで伸びて止まる(shots.mts は終わりの値で撮る) */
286export const growBar = (x: number, y: number, w: number, h: number, fill: string, delay = 0, opacity = 1) =>
287 `<rect x="${x}" y="${y}" width="${Math.max(0, w)}" height="${h}" rx="${Math.min(3, h / 2)}" fill="${fill}"${opacity === 1 ? '' : ` opacity="${opacity}"`}>` +
288 `<animate attributeName="width" from="0" to="${Math.max(0, w)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
289
290/** 下から伸びる縦の棒(下端 bottom・高さ h) */
291export const growColumn = (x: number, bottom: number, w: number, h: number, fill: string, delay = 0) =>
292 `<rect x="${x}" y="${bottom - h}" width="${w}" height="${Math.max(0, h)}" rx="${Math.min(3, w / 2)}" fill="${fill}">` +
293 `<animate attributeName="y" from="${bottom}" to="${bottom - h}" begin="${delay}s" dur="0.6s" fill="freeze"/>` +
294 `<animate attributeName="height" from="0" to="${Math.max(0, h)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
295
296/** ふわっと出る。中身は SVG の断片 */
297export const fadeIn = (body: string, delay = 0) =>
298 `<g opacity="1"><animate attributeName="opacity" from="0" to="1" begin="${delay}s" dur="0.4s" fill="freeze"/>${body}</g>`
299
300/**
301 * 0 から value まで数字が増えて止まる。コマ送りの文字を重ね、最後のコマだけが残る。
302 * attrs は <text> に付ける属性(x・y・font-size・fill など)。format で桁区切りや単位を付ける
303 */
304export function countUp(value: number, attrs: string, format: (n: number) => string = n => String(n), delay = 0) {
305 const STEPS = 8
306 const d = 0.08
307 const last = `<text ${attrs} opacity="0">${esc(format(value))}<animate attributeName="opacity" from="0" to="1" begin="${(delay + (STEPS - 1) * d).toFixed(2)}s" dur="0.01s" fill="freeze"/></text>`
308 if (!Number.isFinite(value) || value === 0) return `<text ${attrs}>${esc(format(value))}</text>`
309 const frames = Array.from({ length: STEPS - 1 }, (_, k) => {
310 const n = Number.isInteger(value) ? Math.round((value * k) / (STEPS - 1)) : (value * k) / (STEPS - 1)
311 return `<text ${attrs} opacity="0">${esc(format(n))}<animate attributeName="opacity" values="1;1" begin="${(delay + k * d).toFixed(2)}s" dur="${d}s"/></text>`
312 })
313 return frames.join('') + last
314}
315types/index.d.ts 43 lines1// live-commentary の $.state の契約
2
3/** 実況 1 行の種類。ログの色分けに使う (error は赤) */
4export type LiveCommentaryKind = 'play' | 'error' | 'cheer' | 'whistle'
5
6export type LiveCommentaryLine = {
7 /** 言った時刻 (ms) */
8 at: number
9 text: string
10 kind: LiveCommentaryKind
11}
12
13/** スコアボード (セッションの間の数) */
14export type LiveCommentaryScore = {
15 ok: number
16 err: number
17 /** 今の連続成功 */
18 streak: number
19}
20
21/** 今のターンの時計 */
22export type LiveCommentaryClock = {
23 /** 主ループのターンが始まった時刻。走っていなければ 0 */
24 startedAt: number
25 /** 今の時刻 (ターン中だけ 1 秒ごとに進める) */
26 now: number
27 /** 前のターンの長さ (ms)。まだなら 0 */
28 lastMs: number
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 'live-commentary': {
34 /** 直近 100 行 (新しい順)。先頭が最新の実況 */
35 log: LiveCommentaryLine[]
36 score: LiveCommentaryScore
37 clock: LiveCommentaryClock
38 /** 実況オン ($.store にも残す) */
39 isOn: boolean
40 }
41 }
42}
43