0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Mac】メニューバーでAIの利用制限を監視!RunCat NeoでAntigravityのレートリミットを可視化する

0
Posted at

はじめに

私は機械知能研究室に所属し,CV分野の研究をしています.

機械知能研究室ではロボットの視覚機能や自律的に行動するための知能システムについての研究に取り組んでいます(ホームページはこちら).

近年,プログラミングや研究の補助としてAIツールを多用するようになりましたが,LLMにはレートリミット(利用制限)が存在します.今回は,Macのメニューバーにシステム情報を表示できるアプリRunCat Neoを使って,Antigravity(IDE)のレートリミットを常にメニューバーで可視化・確認できる仕組みを作成したので,その手法を紹介します.また,おまけとしてGemini Web版の可視化にも挑戦しましたが,こちらは壁にぶつかってしまったため,その失敗談も合わせて共有します.

RunCat Neoとは

RunCatはMacのメニューバーでCPU使用率等に応じて走る猫などのアニメーションを表示するアプリですが,Pro版やRunCat NeoではCustom Metricsとして自分で作成したスクリプトの出力結果(JSON形式)をメニューバーに表示する機能があります.今回はこの機能を利用して,各AIツールのAPIやWeb画面から使用量を取得して表示させます.

image (3).png

※現在,RunCatおよびRunCat NeoはMac版のみ公開されていますのでご注意ください.

Antigravity IDEのQuotaを表示する仕組み

Antigravity(AI搭載エディタ)では,内部でローカルAPIサーバー(language_server)が立ち上がっており,そこにリクエストを送ることでGeminiやClaudeの残りクオータ(Quota)情報を取得できます.

主な処理の流れ

  1. プロセスの特定: ps コマンドで language_server プロセスを探し出します.
  2. トークンとポートの抽出: プロセスの起動引数からAPIを叩くための csrf_token を抽出し,さらに lsof コマンドを用いてそのプロセス(PID)がリッスンしているHTTPSポート番号を特定します.
  3. ローカルAPIの呼び出し: 特定したポートに対して curlGetUserStatus エンドポイントを叩き,JSONレスポンスを取得します.
  4. データの加工: jqbc(シェル上の計算ツール)を使って,GeminiとClaudeそれぞれの remainingFraction (残り使用割合) から「使用率(%)」を計算し,リセット時間(UTC)を日本時間(JST)に変換します.
  5. JSONへのパース: 上記の情報をまとめ,RunCat Neo用のJSONファイルとして出力します.

実際のコード (update-antigravity.sh)

コードを展開する
#!/bin/bash
# update-antigravity.sh
# Antigravity IDEのローカルAPIからQuota情報を取得し、
# RunCat Neo用のCustom Metrics JSONを書き出すスクリプト。

PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/usr/sbin:/bin

OUT_FILE="$HOME/.runcat/antigravity.json"
TMP_FILE="${OUT_FILE}.tmp"

# フォルダが存在しない場合は自動で作成する
mkdir -p "$(dirname "$OUT_FILE")"

# デフォルト値
GEMINI_PCT="0"
CLAUDE_PCT="0"
GEMINI_PCT_INT=0
CLAUDE_PCT_INT=0
GEMINI_RESET="-"
CLAUDE_RESET="-"

# ==========================================
# ユーティリティ関数
# ==========================================

# 1. リセット時刻のフォーマット関数 (UTC -> JST)
format_reset() {
    local utc_str="$1"
    if [ -z "$utc_str" ]; then echo "-"; return; fi
    python3 -c "
import sys
from datetime import datetime, timezone, timedelta

try:
    reset_dt = datetime.strptime('$utc_str', '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc)
    diff = reset_dt - datetime.now(timezone.utc)
    total_sec = int(diff.total_seconds())
    
    if total_sec <= 0:
        print('リセット済')
        sys.exit(0)
        
    hours, remainder = divmod(total_sec, 3600)
    mins = remainder // 60
    jst = reset_dt.astimezone(timezone(timedelta(hours=9)))
    
    print(f'{hours}h{mins:02d}m ({jst.strftime(\"%H:%M\")})')
except Exception:
    print('-')
" 2>/dev/null || echo "-"
}

# 2. 小数点計算から normalizedValue への変換関数
calc_normalized() {
    printf "%.4f" "$(echo "scale=4; $1 / 100" | bc)"
}

# ==========================================
# メイン処理
# ==========================================

fetch_quota() {
    if ! command -v jq &> /dev/null; then
        echo "Error: jq is not installed. Run: brew install jq" >&2
        return 1
    fi

    # 1. language_server プロセスの特定(メインIDEプロセス優先)
    local lang_cmd=$(ps aux | grep "[l]anguage_server" | grep "extension_server_port" | grep -v -- "--enable_lsp" | grep -v -- "--workspace_id" | head -n 1)
    if [ -z "$lang_cmd" ]; then
        lang_cmd=$(ps aux | grep "[l]anguage_server" | grep "extension_server_port" | head -n 1)
    fi

    if [ -z "$lang_cmd" ]; then
        echo "Warning: Antigravity language_server not found." >&2
        return 1
    fi

    # 2. トークンとPIDの抽出
    local csrf_token=$(echo "$lang_cmd" | grep -oE -- '--csrf_token[= ]+[a-zA-Z0-9_-]+' | grep -oE '[a-zA-Z0-9_-]+$')
    local pid=$(echo "$lang_cmd" | awk '{print $2}')
    
    # 3. リッスンしているHTTPSポートの取得
    local https_port=$(lsof -p "$pid" -a -i TCP -a -sTCP:LISTEN 2>/dev/null | awk '{print $9}' | grep -oE '[0-9]+$' | sort -n | head -n 1)

    if [ -z "$csrf_token" ] || [ -z "$https_port" ]; then
        echo "Warning: Could not extract port/token from language_server process." >&2
        return 1
    fi

    # 4. API呼び出し
    local response=$(curl -sk --max-time 10 -X POST \
        -H "Content-Type: application/json" \
        -H "Connect-Protocol-Version: 1" \
        -H "X-Codeium-Csrf-Token: $csrf_token" \
        -d '{}' \
        "https://127.0.0.1:${https_port}/exa.language_server_pb.LanguageServerService/GetUserStatus" 2>/dev/null)

    if [ -z "$response" ]; then
        echo "Warning: Local API call failed." >&2
        return 1
    fi

    # 5. jq で各モデルの情報をパース
    parse_model_info() {
        local model_name="$1"
        local key="$2"
        echo "$response" | jq -r "
            [.userStatus.cascadeModelConfigData.clientModelConfigs[]
            | select(.label | ascii_downcase | contains(\"$model_name\"))
            | .quotaInfo.$key] | first // empty
        " 2>/dev/null
    }

    local gemini_rem=$(parse_model_info "gemini" "remainingFraction")
    local claude_rem=$(parse_model_info "claude" "remainingFraction")
    local gemini_reset_utc=$(parse_model_info "gemini" "resetTime")
    local claude_reset_utc=$(parse_model_info "claude" "resetTime")

    # 6. 使用率(%)の計算
    if [ -n "$gemini_rem" ]; then
        local gemini_raw=$(echo "scale=1; (1 - $gemini_rem) * 100" | bc)
        GEMINI_PCT=$(printf "%.1f" "$gemini_raw" | sed 's/\.0$//')
        GEMINI_PCT_INT=$(printf "%.0f" "$gemini_raw")
    fi

    if [ -n "$claude_rem" ]; then
        local claude_raw=$(echo "scale=1; (1 - $claude_rem) * 100" | bc)
        CLAUDE_PCT=$(printf "%.1f" "$claude_raw" | sed 's/\.0$//')
        CLAUDE_PCT_INT=$(printf "%.0f" "$claude_raw")
    fi

    GEMINI_RESET=$(format_reset "$gemini_reset_utc")
    CLAUDE_RESET=$(format_reset "$claude_reset_utc")
}

# データの取得を実行
if fetch_quota; then
  METRICS_BAR="G: ${GEMINI_PCT}% / C: ${CLAUDE_PCT}%"
  GEMINI_FMT="${GEMINI_PCT}%"
  CLAUDE_FMT="${CLAUDE_PCT}%"
else
  METRICS_BAR="IDE Offline"
  GEMINI_FMT="--"
  CLAUDE_FMT="--"
fi

# ==========================================
# RunCat Neo 用の JSON 出力
# ==========================================
GEMINI_NORM=$(calc_normalized $GEMINI_PCT_INT)
CLAUDE_NORM=$(calc_normalized $CLAUDE_PCT_INT)
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

cat << EOF > "$TMP_FILE"
{
  "title": "Antigravity",
  "symbol": "apple.terminal.fill",
  "metricsBarValue": "${METRICS_BAR}",
  "metrics": [
    {
      "title": "Gemini (5h)",
      "formattedValue": "${GEMINI_FMT}",
      "normalizedValue": ${GEMINI_NORM}
    },
    {
      "title": "Gemini Reset",
      "formattedValue": "${GEMINI_RESET}"
    },
    {
      "title": "Claude (5h)",
      "formattedValue": "${CLAUDE_FMT}",
      "normalizedValue": ${CLAUDE_NORM}
    },
    {
      "title": "Claude Reset",
      "formattedValue": "${CLAUDE_RESET}"
    }
  ],
  "lastUpdatedDate": "${NOW}"
}
EOF

mv "$TMP_FILE" "$OUT_FILE"

表示の様子と拡張機能AGQとの比較

実際にスクリプトを動かすと,Macのメニューバーから以下のようにAntigravityのAPI使用量(GeminiとClaudeそれぞれの残量とリセット時間)をいつでも確認できます.

image-3.png

ちなみに,AntigravityのQuotaを確認するツールとしては,Antigravity IDEの拡張機能である Antigravity Quota (AGQ) が有名です.

image-4.png

image-5.png

AGQはAntigravity IDE内で呼び出して,詳細なモデルごとの残量(Gemini Pro, Flash, Claude Opus, Sonnetなど)を一覧できる非常に優れた拡張機能です.
一方で,今回作成したRunCat Neoを用いた手法もAGQに全く遜色はありません.むしろ以下の点で独自のメリットがあります.

  • エディタを開かなくても確認できる: Antigravity IDEのウィンドウが裏に隠れていても,Macのメニューバーをチラッと見るだけで瞬時に残量がわかります.
  • バー表示で直感的: RunCat Neoのプログレスバー機能(metricsBarValuenormalizedValue)を活用しているため,AGQのバー表示と同様に視覚的にも直感的にわかりやすく仕上がっています.

用途に合わせてAGQと併用するか,メニューバーで素早く確認したい場合は今回のスクリプトを利用するのがおすすめです.

【おまけ】Geminiの使用量表示にも挑戦してみた(失敗談)

実は当初,Gemini Web版(gemini.google.com/usage)の使用量も同時に表示しようと試みていました.公式APIがないため, Chrome DevTools Protocol (CDP) を用いてバックグラウンドでChromeを操作し,情報をスクレイピングするPythonスクリプトを作成しました.

(※スクリプト自体の処理の流れとしては,Chromeのデバッグポートを取得し,裏側でGeminiのページを開いて画面内のテキストから正規表現でパーセンテージを抽出するというものでした.)

作成したスクリプト(記録用)
#!/bin/bash
# fetch-gemini-usage.sh
# Chrome DevTools Protocol (CDP) を使ってバックグラウンドでGeminiの使用量を取得するスクリプト

export PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/usr/sbin:/bin

python3 -c '
import json
import urllib.request
import time
import re
import os
import sys
import subprocess
from datetime import datetime, timezone

# 外部ライブラリ (websocket-client) がない場合は自動でインストールを試みる
try:
    import websocket
except ImportError:
    print("Installing missing dependency: websocket-client...", file=sys.stderr)
    subprocess.check_call([sys.executable, "-m", "pip", "install", "websocket-client", "--quiet"])
    import websocket

# ==============================================================================
# CDP (Chrome DevTools Protocol) 連携用ヘルパー関数
# ==============================================================================

def get_active_port() -> int:
    """DevToolsActivePortから現在ランダムに割り当てられたポートを取得する"""
    dev_tools_file = os.path.expanduser("~/Library/Application Support/Google/Chrome/DevToolsActivePort")
    if os.path.exists(dev_tools_file):
        try:
            with open(dev_tools_file, "r") as f:
                return int(f.readline().strip())
        except Exception:
            pass
    return 9222 # 取得できなかった場合のフォールバック

def get_browser_ws_url(port: int) -> str:
    """ChromeのブラウザレベルのWebSocket URLを取得する"""
    req = urllib.request.Request(f"http://localhost:{port}/json/version")
    with urllib.request.urlopen(req) as response:
        version_info = json.loads(response.read().decode())
    return version_info.get("webSocketDebuggerUrl")

def create_background_target(browser_ws_url: str, url: str) -> str:
    """バックグラウンドで新しいタブを開き,Target IDを返す"""
    ws = websocket.create_connection(browser_ws_url, suppress_origin=True)
    req = {
        "id": 1,
        "method": "Target.createTarget",
        "params": {"url": url, "background": True}
    }
    ws.send(json.dumps(req))
    res = json.loads(ws.recv())
    
    target_id = res.get("result", {}).get("targetId")
    ws.close()
    return target_id

def close_target(browser_ws_url: str, target_id: str):
    """開いたタブを閉じる"""
    ws = websocket.create_connection(browser_ws_url, suppress_origin=True)
    req = {
        "id": 3,
        "method": "Target.closeTarget",
        "params": {"targetId": target_id}
    }
    ws.send(json.dumps(req))
    ws.recv()
    ws.close()

def extract_text_from_target(target_id: str, port: int) -> str:
    """開いたタブのDOMから使用量のテキストデータを抽出する"""
    target_ws_url = f"ws://localhost:{port}/devtools/page/{target_id}"
    ws = websocket.create_connection(target_ws_url, suppress_origin=True)
    
    # 画面内に「リセット」という文字が出現するまで待機するJS
    js_script = """
    (() => {
        const text = document.body.innerText;
        return text.includes("リセット") ? text : null;
    })();
    """
    
    req = {
        "id": 2,
        "method": "Runtime.evaluate",
        "params": {"expression": js_script, "returnByValue": True}
    }
    
    result_text = None
    # 最大15秒間ポーリング
    for _ in range(15):
        time.sleep(1)
        ws.send(json.dumps(req))
        res = json.loads(ws.recv())
        val = res.get("result", {}).get("result", {}).get("value")
        if val:
            result_text = val
            break
            
    ws.close()
    return result_text

# ==============================================================================
# メイン処理
# ==============================================================================

def fetch_gemini_usage_text() -> str:
    """Geminiのページから使用量のテキストを取得する(統合フロー)"""
    try:
        port = get_active_port()
        browser_ws_url = get_browser_ws_url(port)
        if not browser_ws_url:
            return None
            
        target_id = create_background_target(browser_ws_url, "https://gemini.google.com/usage")
        if not target_id:
            return None
            
        text = extract_text_from_target(target_id, port)
        close_target(browser_ws_url, target_id)
        
        return text
    except Exception:
        return None

def parse_and_write_json(text: str):
    """取得したテキストをパースし,RunCat Neo用のJSONを出力する"""
    out_file = os.path.expanduser("~/.runcat/gemini.json")
    tmp_file = out_file + ".tmp"
    
    # 出力先フォルダが存在しない場合は作成
    os.makedirs(os.path.dirname(out_file), exist_ok=True)
    
    # デフォルト値
    cur_pct = 0.0
    wk_pct = 0.0
    daily_r = "-"
    weekly_r = "-"
    
    if text:
        # 正規表現で数値を抽出
        m_cur = re.search(r"現在の使用量.*?(\d+(?:\.\d+)?)%\s*使用中", text, re.DOTALL)
        if m_cur: cur_pct = float(m_cur.group(1))
            
        m_cur_reset = re.search(r"現在の使用量.*?(\d{1,2}:\d{2})にリセット", text, re.DOTALL)
        if m_cur_reset: daily_r = m_cur_reset.group(1)
            
        m_wk = re.search(r"1\s*週間の上限.*?(\d+(?:\.\d+)?)%\s*使用中", text, re.DOTALL)
        if m_wk: wk_pct = float(m_wk.group(1))
            
        m_wk_reset = re.search(r"1\s*週間の上限.*?(\d+月\d+日の\d{1,2}:\d{2})にリセット", text, re.DOTALL)
        if m_wk_reset: weekly_r = m_wk_reset.group(1)
            
    now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    
    metrics = [
        {"title": "Daily", "formattedValue": f"{int(cur_pct)}%", "normalizedValue": cur_pct / 100.0},
        {"title": "Reset", "formattedValue": daily_r}
    ]
    
    if wk_pct > 0 or weekly_r != "-":
        metrics.extend([
            {"title": "Weekly", "formattedValue": f"{int(wk_pct)}%", "normalizedValue": wk_pct / 100.0},
            {"title": "Reset", "formattedValue": weekly_r}
        ])
    
    data = {
        "title": "Gemini",
        "symbol": "sparkles",
        "metricsBarValue": f"{int(cur_pct)}%",
        "metrics": metrics,
        "lastUpdatedDate": now_iso
    }
    
    # アトミック書き込み
    with open(tmp_file, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2, ensure_ascii=False)
    os.replace(tmp_file, out_file)

if __name__ == "__main__":
    text = fetch_gemini_usage_text()
    parse_and_write_json(text)
'"'

しかし,この手法は 結果的に失敗(運用断念) に終わりました.以下にその原因と遭遇した問題点を共有します.

1. 根本的な原因:Chromeのセキュリティ制限の強化

一番の壁となったのは, Google Chromeの自動アップデートによる,強力なセキュリティ制限の追加 です.
これまで,Chromeはデバッグ用のポート(9222番など)を比較的簡単に開くことができました.しかし最近のアップデートにより, 普段使いの(デフォルトの)プロフィールでは,セキュリティ保護のためリモートデバッグ用の通信を強制的にブロックする という非常に厳しい制限が加わりました.(悪意のあるプログラムがブラウザからユーザーのログイン情報や画面を勝手に抜き取れないようにするためのChrome側の対策です)

これにより,これまで通っていた通信がOSレベルで完全に遮断されるようになり,「昨日まで動いていたのに突然動かなくなった」という事態が発生しました.

2. 発生した具体的な問題点

  • ポートが開けない: ターミナルから --remote-debugging-port=9222 という起動オプションを付けてChromeを立ち上げようとしても,Chromeが「このデータディレクトリ(普段のプロフィール)ではデバッグは許可しません」と警告を出し(DevTools remote debugging requires a non-default data directory というメッセージ),ポートを開くことを拒否してしまいます.
  • スクリプトが通信できない: ポートが開いていないため,スクリプトがChromeに接続しようとしても「接続を拒否されました(Connection refused)」というエラーになり,データが取得できません.
  • 結果として表示されない: データが取得できないため,RunCat Neo側に「Not Connected」と表示されてしまいます.
  • 画面のチラつき: さらに,制限が厳しくなる以前にテストしていた段階でも,「スクリプト実行時(バックグラウンドでタブを開いた瞬間)に,まれに画面上に一瞬だけページが映り込んでしまう(フォーカスが奪われる)」という別の問題も抱えていました.

これらの理由から,現在CDPを利用して普段使いのChromeからGeminiの使用量をスクレイピングする手法は現実的ではないと判断し,実装を断念しました.

おわりに

今回は,RunCat NeoのCustom Metrics機能を利用して,Antigravity IDEのレートリミットを可視化する仕組みを紹介しました.RunCat Neoにはスクリプトの実行間隔(Update Interval)を設定できる機能が標準で備わっているため,このスクリプトを登録するだけで,わざわざブラウザやIDEの設定画面を開かなくても,メニューバーをチラッと見るだけでAIの利用状況が把握できるようになります.ペース配分をしながら効率的に開発を進めることが可能になるため,ぜひ試してみてください.

Geminiの可視化に関しては,残念ながらChromeのセキュリティアップデートの壁に阻まれ失敗に終わってしまいましたが,もし「もっとスマートな良い実装方法(ブラウザを邪魔せず安全に取得できる手法)があるよ!」という方がいらっしゃいましたら,ぜひコメント等で教えていただけると嬉しいです.

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?