SLOPSHOPPER

agent-usage

Export Claude rate-limit usage to ~/.agent-usage/claude.json for external displays

newguardtimer
v0.1.0no licenseupdated 2026-10-03stn/ld-agent-usage/claude-mod/agent-usage
A shopper browsing a rack in a slop shop
README

ld-agent-usage

Loupedeck のキーに Claude Code / Codex のレート制限残量と、実行中の Claude Code セッションの状態を表示するプラグイン。

構成は 2 つに分かれている。

ディレクトリ役割
AgentUsagePlugin/Loupedeck(Logi Plugin Service)用プラグイン本体(C# / .NET 10)
claude-mod/agent-usage/Claude Code の mod。残量とセッション状態を ~/.agent-usage/ に書き出す

Codex の残量は ~/.codex/sessions のセッションログをプラグインが直接読むので、Codex 側に追加の設定は要らない。

アクション

Agent Usage(残量)

アクション表示内容
Claude 5h / Claude WClaude の 5 時間枠 / 週枠の残量
Codex 5h / Codex WCodex の 5 時間枠 / 週枠の残量
  • 残量(%)とバー、下段にリセット時刻を表示する。週枠は曜日付き
  • 残量が 20% を切ると赤で表示する
  • キーを押すと、4 つのキーの下段がそろってリセット時刻と残り時間(例: 2h05m)の表示に切り替わる
  • 最後に記録された値がリセット時刻を過ぎていれば 100% とみなす

Agent Sessions(セッション状態)

「セッション 1」〜「セッション 12」のスロットに、Claude Code のセッションを次の順で並べて表示する。

  1. 回答待ち(Wait): AskUserQuestion / ExitPlanMode の応答待ち、または権限ダイアログ表示中。背景が赤くなり、待っているツール名を下段に出す
  2. 実行中(Run)
  3. 待機中

同じ状態の中では最後に状態が変わったものが先に来る。背景にはプロジェクト名(作業ディレクトリ名)を大きく敷く。キーを押すと再読み込みする。

mod は 60 秒ごとに heartbeat を書き、3 分以上途絶えたセッションと終了したセッションはプラグイン側でファイルごと削除する。

セットアップ

1. Loupedeck プラグインをビルドする

前提: Logi Plugin Service(Loupedeck 6.0 以降)、.NET 10 SDK。対応デバイスは Loupedeck CT / Live / Live S と Razer Stream Controller 系。

cd AgentUsagePlugin
dotnet build -c Debug

ビルド後に Logi Plugin Service のプラグインディレクトリへ .link ファイルが作られ、プラグインの再読み込みが走るので、そのまま Loupedeck の設定画面から「Agent Usage」「Agent Sessions」のアクションをキーに割り当てられる。再読み込みに失敗した場合は Logi Plugin Service を再起動する。

パッケージ化
cd AgentUsagePlugin
dotnet build -c Release
logiplugintool pack ./bin/Release ./AgentUsage.lplug4
logiplugintool install ./AgentUsage.lplug4

VS Code では .vscode/tasks.json に同じ手順をタスクとして登録してある。

2. Claude Code の mod を入れる

claude-mod/agent-usage/ を --plugin-dir で Claude Code に読み込ませる。

claude --plugin-dir path/to/ld-agent-usage/claude-mod/agent-usage

読み込まれると、セッション開始時とレート制限の更新時に次のファイルを書き出す。

~/.agent-usage/
├── claude.json             # five_hour / seven_day の percentUsed と resetsAt
└── sessions/<sessionId>.json  # state, detail, cwd, updatedAt, heartbeatAt

ライセンス

MIT

Source 1 files
hooks/register.ts 105 lines
1import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
2
3type SessionState = 'idle' | 'running' | 'waiting' | 'ended'
4
5// These tools block on the user until they return; everything else waits only while a permission dialog is open
6const QUESTION_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
7const HEARTBEAT_MS = 60_000
8
9let turnActive = false
10let updatedAt = ''
11// tool_use_id -> tool name, for calls currently waiting on the user
12const waitingOn = new Map<string, string>()
13
14const currentState = (): SessionState => (waitingOn.size > 0 ? 'waiting' : turnActive ? 'running' : 'idle')
15
16const homeDir = async ($: EngineInterface) => (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
17
18const writeUsage = async ($: EngineInterface, rateLimits: SessionRateLimit[]) => {
19  if (rateLimits.length === 0) return
20  const home = await homeDir($)
21  if (home === undefined) return
22  const windows = Object.fromEntries(
23    rateLimits.map(r => [r.kind, { percentUsed: r.percentUsed, resetsAt: r.resetsAt ?? null }]),
24  )
25  const updatedAt = new Date(await $.clock.now()).toISOString()
26  await $.fs.write(`${home}/.agent-usage/claude.json`, JSON.stringify({ updatedAt, ...windows }, null, 2))
27}
28
29// updatedAt marks the last state change; heartbeatAt alone moves on the timer so the reader can drop dead sessions
30const writeSession = async ($: EngineInterface, state: SessionState, sessionId?: string, touch = true) => {
31  const home = await homeDir($)
32  if (home === undefined) return
33  const id = sessionId ?? (await $.session.id())
34  const now = new Date(await $.clock.now()).toISOString()
35  if (touch || updatedAt === '') updatedAt = now
36  const detail = state === 'waiting' ? ([...waitingOn.values()].at(-1) ?? null) : null
37  const record = { sessionId: id, cwd: await $.session.cwd(), state, detail, updatedAt, heartbeatAt: now }
38  await $.fs.write(`${home}/.agent-usage/sessions/${id}.json`, JSON.stringify(record, null, 2))
39}
40
41const heartbeat = ($: EngineInterface) => writeSession($, currentState(), undefined, false)
42
43const stopWaiting = async ($: EngineInterface, ...keys: string[]) => {
44  const removed = keys.filter(k => waitingOn.delete(k))
45  if (removed.length > 0) await writeSession($, currentState())
46}
47
48export const register: Register = on => {
49  on('session.start', async ($, e, next) => {
50    const result = await next(e)
51    await writeUsage($, (await $.session.usage()).rateLimits)
52    // Not gated on isInteractive: the VS Code extension runs through the SDK and never got a heartbeat
53    await writeSession($, currentState())
54    $.clock.every(HEARTBEAT_MS, () => heartbeat($))
55    return result
56  })
57
58  on('session.measure', async ($, e, next) => {
59    if (e.changed.includes('rateLimits')) await writeUsage($, e.rateLimits)
60    return next(e)
61  })
62
63  on('turn.start', async ($, e, next) => {
64    turnActive = true
65    await writeSession($, currentState())
66    return next(e)
67  })
68
69  on('turn.complete', async ($, e, next) => {
70    if (e.agentId === undefined) {
71      turnActive = false
72      waitingOn.clear()
73      await writeSession($, currentState())
74    }
75    return next(e)
76  })
77
78  // tool.check answers 'ask' before the mode (e.g. auto) settles it, so only PermissionRequest means a dialog is shown.
79  // It carries no tool_use_id, hence the key by tool name, cleared when that tool's call resolves.
80  on('classic.PermissionRequest', async ($, e, next) => {
81    waitingOn.set(`perm:${e.tool_name}`, e.tool_name)
82    await writeSession($, currentState())
83    return next(e)
84  })
85
86  on('tool.call', async ($, e, next) => {
87    if (QUESTION_TOOLS.has(e.tool)) {
88      waitingOn.set(e.tool_use_id, e.tool)
89      await writeSession($, currentState())
90    }
91    try {
92      return await next(e)
93    } finally {
94      await stopWaiting($, e.tool_use_id, `perm:${e.tool}`)
95    }
96  })
97
98  on('session.end', async ($, e, next) => {
99    turnActive = false
100    waitingOn.clear()
101    await writeSession($, 'ended', e.sessionId)
102    return next(e)
103  })
104}
105