SLOPSHOPPER

ai-usage-hub

Claude・Codex・Grok・Grok Bot の使用率とリセット時刻を表示します

newpanecommandprocesstimer
★ 1v0.1.0MITupdated 2026-10-15marulimo/claude-code-mods/mods/ai-usage-hub
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ai-usage-hub
│ ┃ AI Usage Hub ✕ › fix the failing auth test and add an audit log call │ ┃ AI Usage Hub │ ┃ ⏺ Read(src/auth.ts) │ ┃ 最終更新 08:53 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ 更新 ] ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ╭──────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ Claude │ │ ┃ │ Context 49% │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 5時間 │ │ ┃ │ 31% │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ██████░░░░░░░░░░░░░░ │ │ ┃ │ │ › /usage-hub │ ┃ │ 週次 │ ⎿ ai-usage-hub: AI Usage Hub を開きました │ ┃ │ まだ数値がありません(Claude │ │ ┃ │ が応答すると出ます) │ │ ┃ │ 取得失敗 08:53 │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Codex │ │ ┃ │ 使用率 │ │ ┃ │ データなし │ │ ┃ │ 取得失敗 08:53 │ │ ┃ ╰──────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · AI Usage Hub
AI Usage Hub 最終更新 08:53 [ 更新 ] ╭──────────────────────────────────────────────────────────╮ │ Claude │ │ Context 49% │ │ 5時間 │ │ 31% │ │ ██████░░░░░░░░░░░░░░ │ │ │ │ 週次 │ │ まだ数値がありません(Claude が応答すると出ます) │ │ 取得失敗 08:53 │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Codex │ │ 使用率 │ │ データなし │ │ 取得失敗 08:53 │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Grok │ │ 使用率 │ │ データなし │ │ 取得失敗 08:53 │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Gemini │ │ 使用率 │ │ データなし │ │ 取得失敗 08:53 │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Devin │ │ 使用率 │ │ データなし │ │ 取得失敗 08:53 │ ╰──────────────────────────────────────────────────────────╯
README

Claude Code Mods

Claude Code の Mod(10/1 公開の拡張機能)6本をまとめたリポジトリです。

インストール

ターミナルの Claude Code で、<mod> を下記の名前に置き換えて実行します。

/plugin install <mod> --marketplace marulimo/claude-code-mods

続けて y を入力し、インストール先のスコープを選びます。デスクトップアプリの Code タブではこのコマンドは使えないため、ターミナルでインストールしてください。user スコープに入れれば、デスクトップのセッションでも読み込まれます。

Mod の設定項目は各 mods/<mod>/.claude-plugin/plugin.json の userConfig に定義しています。実行コマンドは PATH 上で見つかる必要があります。別の場所にある場合は設定で実行ファイルのパスを指定してください。

ask-codex

右側のパネルから、Devin SWE-2 への相談と Codex への作業依頼を送ります。送った分は Devin と Codex の利用枠で動き、Claude の使用量は使いません。単独のリポジトリ marulimo/ask-codex でも同じ版を公開しています。

  • 使い方: パネル上部の「Devin(相談)」「Codex(作業)」で送り先を選び、入力欄に書いて Enter。「推論」で強さを選びます(送り先ごとに覚えます)。コマンドでも送れます: /devin <質問>、/codex <依頼>、/codex-panel。
  • 必要なもの: 使う送り先の CLI です(片方だけでも動きます)。Codex CLI はログイン済み、Devin CLI はログイン済みで SWE-2 が使えること。
  • 設定: Codex の権限(workspace-write)、Codex の推論強度(high)、Codex のモデル(空なら CLI の既定)、codex の場所(空なら自動で探す)、Devin の強さ(high)、devin の場所(空なら自動で探す)、Devin を動かすフォルダー(空なら ~/.ask-codex/devin-chat)です。
  • 注意: Codex の権限を danger-full-access にすると、確認もサンドボックスもなしで作業フォルダーを書き換えます。Devin は中身の空な専用フォルダーで、その1回だけフォルダーの信頼確認を省いて動かします。失敗の記録は ~/.ask-codex/errors.log に残ります。
  • 詳しくは mods/ask-codex/README.md を参照してください。

ai-usage-hub

Claude、Codex、Grok、Gemini、Devin の使用率とリセット時刻をパネルに表示します。Claude のセッション情報と外部 collector の結果を使います。

  • コマンド: /usage-hub。起動時にもパネルを開き、5分おきに取得します。
  • 必要なもの: Node.js と、各サービスの使用率を集める collector が別途必要です。collector とそのログイン処理はこのリポジトリには含まれません。
  • 設定: userConfig はありません。環境変数 AI_USAGE_NODE(既定 node)と AI_USAGE_DASH_DIR(既定は USERPROFILE または HOME の下の ai-usage-dashboard)を使います。
  • 呼び出し: collector のフォルダーを作業ディレクトリとして、次を実行します。終了コード 0 で、標準出力に JSON だけを返してください。
node <AI_USAGE_DASH_DIR>/server.mjs --json codex,grok,gemini,devin,claude

JSON は次の形です。groups は必須配列で、グループには文字列の name と配列の rows が必要です。行には文字列の label と有限の数値または null の used が必要です。used は使用率の百分率です。

{
  "groups": [
    {
      "name": "CODEX",
      "rows": [
        { "label": "5時間", "used": 25, "reset": "2026-10-16T00:00:00Z" },
        { "label": "週次", "used": null, "state": "データなし" }
      ],
      "stale": false,
      "note": null
    }
  ]
}

認識するグループ名は CLAUDE、CODEX、GROK、GEMINI、DEVIN です。省略可能な項目は、行の reset(日時文字列)、state(状態文字列)、star(真なら Claude の Fable 枠)、グループの stale(真なら古いデータ)、note(文字列)です。Claude の補完行は 5時間 と 週次 で始まるラベルを使います。取得に失敗したときは前回値を残すため、表示の失敗時刻も確認してください。

ai-router

入力を送信すると、依頼に適した AI の判定結果をトーストとステータス行に表示します。Claude への送信は判定の完了を待ちません。

  • コマンド: ありません。通常の入力送信時に動きます。スラッシュコマンド、空白を除いて8文字未満の入力などは判定しません。
  • 必要なもの: Python と外部の判定スクリプトです。スクリプト、利用先 API の設定や認証は同梱しません。
  • 設定: pythonPath(python)、routerScript(空)、backend(jev、または decisions)、privateFolders(空)、privateWords(空)です。
  • routerScript が空なら実行せず、「判定スクリプト未設定」と表示します。
  • privateFolders は除外する絶対パス、privateWords は除外する語句をカンマ区切りで指定します。フォルダー自身とその配下、語句の部分一致を大文字小文字を区別せず判定します。既定ではどちらも除外しません。
  • 注意: 設定すると入力全文を判定スクリプトの標準入力へ渡します。外部送信の有無はスクリプト側の実装にも依存します。

呼び出しは次の形です。入力全文を標準入力から読み、12秒以内に終了コード 0 と標準出力の JSON を返す必要があります。

python <routerScript> --backend jev

backend を decisions にすると末尾の引数も decisions になります。成功結果の例です。

{
  "ok": true,
  "label": "候補A",
  "entry": "作業の入口",
  "top": [{ "id": "candidate_a", "p": 0.82 }, { "id": "candidate_b", "p": 0.11 }]
}

成功時は ok: true、文字列の label と entry、1件以上の top が必要です。p は 0〜1 の有限数なら百分率で表示します。2件目の id と p が有効なら次点も表示します。失敗時は {"ok":false,"reason":"service_unavailable"} の形を返せます。reason は英字で始まる英数字・アンダースコア80文字以内だけを表示します。それ以外は一般的な失敗表示になります。

side-chat

Claude が考えている間、Dots 役(ChatGPT)と GrokBot 役(Grok)がパネルで交互に、いまの会話の話題について雑談します。直近の会話(最大8発言・1発言300字・合計1500字)、このターンの使用ツール名、直近の雑談を送り先に渡します。

  • 送らないもの: ツールの出力とコードブロックは渡しません。設定 privateWords の語句を含む発言は丸ごと省き、APIキー・トークン・メールアドレス・32文字以上の英数字列は伏せ字にします。
  • コマンド: /side-chat、/side-chat off、/side-chat on です。
  • 必要なもの: ログイン済みの Codex CLI と、hermes_cli を読み込める Python / Hermes Agent です。Grok は xai-oauth 経由で X Premium の枠を使います。
  • 設定: codexPath(codex)、gptModel(gpt-6.1-sol)、python(py)、pythonVersion(-3.13)、grokModel(grok-4.3)、startDelaySec(20秒)、gapSec(20秒)、maxLines(6)、privateFolders(空)、privateWords(空)です。Python ランチャーに版指定が不要なら pythonVersion を空にします。
  • privateFolders は除外する絶対パスをカンマ区切りで指定します。設定したフォルダーとその配下では送りません。既定では除外しません。
  • privateWords は雑談の材料に渡さない語句をカンマ区切りで指定します。大文字小文字を区別せず部分一致で判定します。既定では除外しません。
  • 注意: 雑談ごとに Codex / Grok の利用枠を使います。不要な場合は off で止めてください。

tag-feed

X のハッシュタグに合う投稿を探し、パネルに新着を表示します。タグを登録すると定期取得します。

  • コマンド: /tag-feed #タグ #別のタグ、/tag-feed です。パネルには即時取得と停止・再開のボタンがあります。
  • 必要なもの: hermes_cli を読み込める Python / Hermes Agent と、Grok の xai-oauth ログインです。
  • 設定: python(py)、pythonVersion(-3.13)、model(grok-4.3)、intervalMinutes(10分、最短2分)です。
  • 注意: X API は使いません。Grok の検索で投稿 URL を探し、X の埋め込み用の公開エンドポイントから本文を取得します。取得ごとに Grok の X Premium 枠を使います。検索結果や公開エンドポイントの状況により取得できない投稿もあります。

x-post

本文に登録タグを付け、ブラウザで X の投稿画面や返信画面を開きます。最後の「ポスト」は人が押します。この Mod が自分で投稿することはありません。

  • コマンド: /x 本文、/x-reply <投稿URL> 本文、/x-tag [add|rm|clear] [タグ]、/x-mode on|off、/x-account @名前、/x-pane です。
  • ペイン: 上にハッシュタグ(入力欄で追加、押すと削除)とメンション、下に投稿欄(返信先と本文。Enter で投稿画面を開く)を並べます。本文はハッシュタグ込みのプレビューと文字数の目安を出します。
  • 必要なもの: X にログインしたブラウザです。メンション検索には別途、PATH 上の Hermes Agent(hermes)と Grok のログインが必要です。hermes が無くても投稿はできます。
  • 設定: userConfig はありません。タグ、推論中だけ投稿可能にするモード、自分のアカウントを上記コマンドで保存します。アカウントの既定値は空で、未設定ならペインに /x-account @名前 で設定 と出ます。hermes のモデルとプロバイダーは環境変数 X_POST_HERMES_MODEL(既定 grok-4.3)と X_POST_HERMES_PROVIDER(既定 xai-oauth)で変えられます。
  • 上限: タグは10個、1つ50文字までです。本文がタグ込みで280を超えると投稿画面を開きません(X に合わせ、日本語などは1文字2、URL は23と数える目安です)。
  • 注意: X API キーは不要です。メンション検索は「更新」を押したときだけ動き、1回30〜40秒かかり Grok の枠を使います。検索なので取りこぼしがあり、X の通知画面の代わりにはなりません。ブラウザは Windows では rundll32、macOS では open、それ以外では xdg-open で開きます。開けなければコマンドが返すリンクから開いてください。デスクトップ版ではペインの幅を Mod から変えられないので、狭いときは境目をドラッグしてください。

動作環境とライセンス

作者は Windows 11 で動かしています。他 OS は未確認です。Mod 対応の Claude Code が必要です。

MIT ライセンスです。詳しくは LICENSE を参照してください。

Source 3 files
hooks/register.tsx 122 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { HubState } from '../types'
4import { barColor, buildCards, parseUsage, recommend, resetLabel, retainCard, svgBar, textBar, timeLabel } from './usage'
5
6const PANE = 'ai-usage-hub'
7const hub = atom({ plugin: 'ai-usage-hub', key: 'hub' } as const, {
8  cards: buildCards(null, null), updatedAt: null, lastAttempt: null, collecting: false, now: 0,
9} as HubState)
10
11async function collect($: EngineInterface): Promise<void> {
12  const started = await $.clock.now()
13  let claimed = false
14  await update($, hub, state => {
15    claimed = !state.collecting && (state.lastAttempt === null || started - state.lastAttempt >= 60_000)
16    return claimed ? { ...state, collecting: true, lastAttempt: started } : state
17  })
18  if (!claimed) return
19  try {
20    const [external, claude] = await Promise.allSettled([
21      (async () => {
22        const node = (await $.env.get('AI_USAGE_NODE')) || 'node'
23        const dir = (await $.env.get('AI_USAGE_DASH_DIR')) ||
24          `${(await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''}/ai-usage-dashboard`
25        const result = await $.process.run([node, `${dir.replace(/[\\/]$/, '')}/server.mjs`, '--json', 'codex,grok,gemini,devin,claude'], {
26          cwd: dir, timeoutMs: 180_000,
27        })
28        if (result.exitCode !== 0 || result.isStdoutTruncated) throw new Error('Usage process failed')
29        return parseUsage(result.stdout)
30      })(),
31      $.session.usage(),
32    ])
33    const now = await $.clock.now()
34    const cards = buildCards(external.status === 'fulfilled' ? external.value : null,
35      claude.status === 'fulfilled' ? claude.value : null)
36    await update($, hub, state => ({
37      ...state, now, updatedAt: now,
38      cards: cards.map((card, index) => {
39        const failed = index === 0 ? card.rows.some(row => row.used === null) ||
40          (external.status === 'rejected' && state.cards[0]?.rows.some(row => row.star || row.label.includes('Fable')) === true) ||
41          (external.status === 'fulfilled' && external.value.groups.find(group => group.name === 'CLAUDE')?.stale === true) :
42          external.status === 'rejected' || card.rows.some(row => row.used === null) ||
43          (index === 1 && external.value.groups.find(group => group.name === 'CODEX')?.stale === true)
44        // A completely failed fetch must keep every previous row, including labels.
45        let incoming = (index === 0 ? claude.status === 'rejected' && external.status === 'rejected' : external.status === 'rejected')
46          ? { ...card, rows: state.cards[index]?.rows ?? card.rows } : card
47        if (index === 0 && !incoming.rows.some(row => row.star || row.label.includes('Fable')) &&
48            (external.status === 'rejected' || external.value.groups.find(group => group.name === 'CLAUDE')?.stale === true)) {
49          const fable = state.cards[0]?.rows.find(row => row.star || row.label.includes('Fable'))
50          if (fable) incoming = { ...incoming, rows: [...incoming.rows, fable] }
51        }
52        return retainCard(state.cards[index], incoming, failed ? now : undefined)
53      }),
54    }))
55  } finally {
56    await update($, hub, state => ({ ...state, collecting: false }))
57  }
58}
59
60export const register: Register = on => {
61  on('session.start', async ($, e, next) => {
62    await $.command.register({ name: 'usage-hub', description: 'AI使用率のペインを開きます' })
63    void $.ui.open({ id: PANE, title: 'AI Usage Hub' })
64    const now = await $.clock.now()
65    await update($, hub, state => ({ ...state, collecting: false, now }))
66    $.clock.every(300_000, () => { void collect($) })
67    $.clock.every(60_000, () => {
68      void (async () => {
69        const now = await $.clock.now()
70        await update($, hub, state => ({ ...state, now }))
71      })()
72    })
73    void collect($)
74    return next(e)
75  })
76
77  on('command.run', { command: 'usage-hub' }, async $ => {
78    await $.ui.open({ id: PANE, title: 'AI Usage Hub' })
79    return { text: 'AI Usage Hub を開きました' }
80  })
81
82  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
83    const { Box, Text, Button } = $.ui.resolve(e)
84    const state = await read($, hub)
85    const advice = recommend(state.cards)
86    return (
87      <Box flexDirection="column" gap={1}>
88        <Text bold>AI Usage Hub</Text>
89        <Text dimColor>最終更新 {state.updatedAt === null ? '--:--' : timeLabel(state.updatedAt)}</Text>
90        <Button key="refresh" label="更新" onPress={() => { void collect($) }} />
91        {state.collecting && <Text dimColor>取得中…</Text>}
92        {state.cards.map(card => (
93          <Box key={card.name} flexDirection="column" borderStyle="round" borderColor={card.color} paddingX={1}>
94            <Text bold color={card.color}>{card.name}</Text>
95            {card.context !== undefined && <Text dimColor>Context {card.context}%</Text>}
96            {card.rows.map(row => (
97              <Box flexDirection="column">
98                <Text>{row.label}</Text>
99                {row.used === null ? <Text dimColor>{row.state || card.note || 'データなし'}</Text> : (
100                  <Box flexDirection="column">
101                    <Text bold color={card.color}>{row.used}%</Text>
102                    {e.surface === 'terminal' ? (
103                      <Text color={barColor(row.used, card.color)}>{textBar(row.used)}</Text>
104                    ) : (() => {
105                      const { Svg } = $.ui.resolve(e)
106                      return <Svg source={svgBar(row.used, card.color)} alt={`${row.used}% 使用`} height={10} />
107                    })()}
108                    {!!row.reset && <Text dimColor>{resetLabel(row.reset, state.now)}</Text>}
109                  </Box>
110                )}
111              </Box>
112            ))}
113            {!!card.note && <Text dimColor>{card.note}</Text>}
114            {card.failedAt !== undefined && <Text dimColor>取得失敗 {timeLabel(card.failedAt)}</Text>}
115          </Box>
116        ))}
117        {advice !== null && <Text>おすすめ: {advice}</Text>}
118      </Box>
119    )
120  })
121}
122
hooks/usage.ts 165 lines
1import type { SessionUsage } from 'claude-code'
2import type { UsageCard, UsagePayload, UsageRow } from '../types'
3
4const EMPTY_CLAUDE = 'まだ数値がありません(Claude が応答すると出ます)'
5
6// Shared pure conversions: the Node 24 collector and the Mod tests use the same code.
7export function geminiRows(buckets?: { window?: string; remaining_fraction?: unknown; reset_time?: string }[]): UsageRow[] {
8  return (['5h', 'weekly'] as const).map(window => {
9    const bucket = buckets?.find(bucket => bucket.window === window)
10    const remaining = bucket?.remaining_fraction
11    const valid = typeof remaining === 'number' && Number.isFinite(remaining) && remaining >= 0 && remaining <= 1
12    return { label: window === '5h' ? '5時間' : '週次', used: valid ? Math.round((1 - remaining) * 100) : null,
13      reset: bucket?.reset_time ?? null, ...(!valid ? { state: 'データなし' } : {}) }
14  })
15}
16
17export function devinRows(plan?: {
18  dailyQuotaRemainingPercent?: unknown; weeklyQuotaRemainingPercent?: unknown
19  dailyQuotaResetAtUnix?: string; weeklyQuotaResetAtUnix?: string; planInfo?: { planName?: string }
20}): UsageRow[] {
21  return (['daily', 'weekly'] as const).map(window => {
22    const remaining = plan?.[`${window}QuotaRemainingPercent`]
23    const valid = typeof remaining === 'number' && Number.isFinite(remaining) && remaining >= 0 && remaining <= 100
24    const seconds = plan?.[`${window}QuotaResetAtUnix`]
25    const reset = seconds ? new Date(Number(seconds) * 1000) : null
26    return { label: `${window === 'daily' ? '日次' : '週次'}${plan?.planInfo?.planName ? ` · ${plan.planInfo.planName}` : ''}`,
27      used: valid ? 100 - remaining : null,
28      reset: reset && Number.isFinite(reset.getTime()) ? reset.toISOString() : null,
29      ...(!valid ? { state: '取得失敗' } : {}) }
30  })
31}
32
33export function parseUsage(stdout: string): UsagePayload {
34  const data: unknown = JSON.parse(stdout)
35  if (!data || typeof data !== 'object' || !('groups' in data) || !Array.isArray(data.groups)) {
36    throw new Error('Invalid usage groups')
37  }
38  const groups = data.groups.map((group: unknown) => {
39    if (!group || typeof group !== 'object' || !('name' in group) || typeof group.name !== 'string' ||
40        !('rows' in group) || !Array.isArray(group.rows)) throw new Error('Invalid usage group')
41    const rows: UsageRow[] = group.rows.map((row: unknown) => {
42      if (!row || typeof row !== 'object' || !('label' in row) || typeof row.label !== 'string' ||
43          !('used' in row) || !(row.used === null || (typeof row.used === 'number' && Number.isFinite(row.used)))) {
44        throw new Error('Invalid usage row')
45      }
46      return {
47        label: row.label, used: row.used,
48        reset: 'reset' in row && typeof row.reset === 'string' ? row.reset : null,
49        state: 'state' in row && typeof row.state === 'string' ? row.state : undefined,
50        ...('star' in row && row.star === true ? { star: true } : {}),
51      }
52    })
53    return {
54      name: group.name, rows,
55      stale: 'stale' in group && group.stale === true,
56      note: 'note' in group && typeof group.note === 'string' ? group.note : null,
57    }
58  })
59  return { groups }
60}
61
62export function buildCards(payload: UsagePayload | null, usage: SessionUsage | null): UsageCard[] {
63  const codex = payload?.groups.find(group => group.name === 'CODEX')
64  const grok = payload?.groups.find(group => group.name === 'GROK')
65  const claude = payload?.groups.find(group => group.name === 'CLAUDE')
66  const gemini = payload?.groups.find(group => group.name === 'GEMINI')
67  const devin = payload?.groups.find(group => group.name === 'DEVIN')
68  let grokRows = grok?.rows ?? []
69  const [first, second] = grokRows
70  if (grokRows.length === 2 && first && second &&
71      typeof first.used === 'number' && typeof second.used === 'number' && first.used === second.used &&
72      Number.isFinite(Date.parse(first.reset ?? '')) && Date.parse(first.reset!) === Date.parse(second.reset ?? '')) {
73    grokRows = [{ ...first, label: '週次 · 共有枠(Grok + Grok Bot)' }]
74  }
75  const claudeRows = (['five_hour', 'seven_day'] as const).map((kind): UsageRow => {
76    const limit = usage?.rateLimits.find(limit => limit.kind === kind)
77    const fallback = claude?.rows.find(row => kind === 'five_hour' ? row.label === '5時間' :
78      row.label.startsWith('週次') && !row.star && !row.label.includes('Fable'))
79    const used = limit && Number.isFinite(limit.percentUsed) ? limit.percentUsed : fallback?.used ?? null
80    return {
81      label: kind === 'five_hour' ? '5時間' : '週次',
82      used,
83      reset: limit && Number.isFinite(limit.percentUsed) ? limit.resetsAt : fallback?.reset,
84      state: used === null ? fallback?.state || claude?.note || EMPTY_CLAUDE : undefined,
85    }
86  })
87  const fable = claude?.rows.find(row => row.star || row.label.includes('Fable'))
88  if (fable) claudeRows.push({ ...fable, label: '週次 · Fable' })
89  const cards: UsageCard[] = [
90    { name: 'Claude', color: '#d97757', rows: claudeRows, context: usage?.context.percent, note: claude?.note },
91    { name: 'Codex', color: '#59c39a', rows: codex?.rows ?? [], note: codex?.note },
92    { name: 'Grok', color: '#e8e8ea', rows: grokRows, note: grok?.note },
93    { name: 'Gemini', color: '#4c8bf5', rows: gemini?.rows ?? [], note: gemini?.note },
94    { name: 'Devin', color: '#2dd4bf', rows: devin?.rows ?? [], note: devin?.note },
95  ]
96  for (const card of cards.slice(1)) {
97    if (card.rows.length === 0) card.rows = [{ label: '使用率', used: null, state: card.note || 'データなし' }]
98  }
99  return cards
100}
101
102export function retainCard(previous: UsageCard | undefined, incoming: UsageCard, failedAt?: number): UsageCard {
103  if (failedAt === undefined) return incoming
104  if (incoming.rows.every(row => row.used === null) && previous?.rows.some(row => row.used !== null)) {
105    return { ...incoming, rows: previous.rows, context: incoming.context ?? previous.context,
106      note: incoming.note || incoming.rows.find(row => row.state)?.state, failedAt }
107  }
108  const rows = incoming.rows.map(row => {
109    if (row.used !== null) return row
110    const old = previous?.rows.find(old => old.label === row.label) ??
111      (previous?.rows.length === 1 && previous.rows[0]?.label === '週次 · 共有枠(Grok + Grok Bot)'
112        ? { ...previous.rows[0], label: row.label } : undefined) ??
113      (incoming.rows.length === 1 && previous?.rows.length === 1 ? previous.rows[0] : undefined)
114    return old?.used !== null && old?.used !== undefined ? old : row
115  })
116  return { ...incoming, rows, context: incoming.context ?? previous?.context, failedAt }
117}
118
119export function recommend(cards: UsageCard[]): string | null {
120  let best: { name: string; max: number } | undefined
121  for (const card of cards) {
122    const values = card.rows.flatMap(row => row.used === null ? [] : [row.used])
123    if (card.failedAt !== undefined || values.length === 0) continue
124    const max = Math.max(...values)
125    if (!best || max < best.max) best = { name: card.name, max }
126  }
127  if (!best) return null
128  const conserve = cards.find(card => card.name === 'Claude')?.rows.some(row => row.label === '5時間' && (row.used ?? 0) >= 80)
129  return `いまは ${best.name} を使うのがおすすめ${conserve ? ' · Claudeの5時間枠を温存' : ''}`
130}
131
132const pad = (value: number) => String(value).padStart(2, '0')
133
134export function timeLabel(ms: number): string {
135  const date = new Date(ms)
136  return `${pad(date.getHours())}:${pad(date.getMinutes())}`
137}
138
139export function resetLabel(reset: string | null | undefined, now: number): string {
140  const end = Date.parse(reset ?? '')
141  if (!Number.isFinite(end)) return ''
142  const remaining = Math.max(0, end - now)
143  if (remaining === 0) return `リセット ${timeLabel(end)} · リセット待ち`
144  if (remaining < 86_400_000) {
145    const minutes = Math.ceil(remaining / 60_000)
146    return `リセット ${timeLabel(end)} · あと${Math.floor(minutes / 60)}h${minutes % 60}m`
147  }
148  const date = new Date(end)
149  return `リセット ${date.getMonth() + 1}/${date.getDate()} ${timeLabel(end)} · あと${Math.floor(remaining / 86_400_000)}日`
150}
151
152export function barColor(used: number, color: string): string {
153  return used >= 80 ? '#ef4444' : color
154}
155
156export function textBar(used: number): string {
157  const filled = Math.round(Math.min(100, Math.max(0, used)) / 5)
158  return '█'.repeat(filled) + '░'.repeat(20 - filled)
159}
160
161export function svgBar(used: number, color: string): string {
162  const width = Math.min(100, Math.max(0, used)) * 3
163  return `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="10" viewBox="0 0 300 10"><rect width="300" height="10" rx="5" fill="#3f3f46"/><rect width="${width}" height="10" rx="5" fill="${barColor(used, color)}"/></svg>`
164}
165
types/index.d.ts 40 lines
1export type UsageRow = {
2  label: string
3  used: number | null
4  reset?: string | null
5  state?: string
6  star?: boolean
7}
8
9export type UsageGroup = {
10  name: string
11  rows: UsageRow[]
12  stale?: boolean
13  note?: string | null
14}
15
16export type UsagePayload = { updatedAt?: string; groups: UsageGroup[] }
17
18export type UsageCard = {
19  name: string
20  color: string
21  rows: UsageRow[]
22  note?: string | null
23  context?: number
24  failedAt?: number
25}
26
27export type HubState = {
28  cards: UsageCard[]
29  updatedAt: number | null
30  lastAttempt: number | null
31  collecting: boolean
32  now: number
33}
34
35declare module 'claude-code' {
36  interface PluginState {
37    'ai-usage-hub': { hub: HubState }
38  }
39}
40