SLOPSHOPPER

usage-dash

Quota, Claude sessions and background jobs pane

newpaneguardcommandtoastprompt
★ 336v0.1.1AGPL-3.0updated 2026-10-07aqua5230/usage/claude_pane/usage-dash
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-dash
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ (none) │ ⏺ Read(src/auth.ts) │ ┃ │ Cannot read quota: SyntaxError: JSON │ ⎿ Read 6 lines │ ┃ │ Parse error: Unexpected EOF; │ ⏺ Update(src/auth.ts) │ ┃ │ SyntaxError: JSON Parse error: │ ⎿ Added 2 lines, removed 1 line │ ┃ │ Unexpected EOF │ ⏺ Bash(bun test) │ ┃ ╰──────────────────────────────────────────╯ ⎿ 3 pass, 1 fail │ ┃ [ ▾ Quota ] │ ┃ ╭──────────────────────────────────────────╮ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ (none) │ │ ┃ │ │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰──────────────────────────────────────────╯ │ ┃ [ ▾ Claude sessions ] 0 busy / 0 › /usage-dash │ ┃ ╭──────────────────────────────────────────╮ ⎿ usage-dash: Usage pane opened. │ ┃ │ (none) │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ [ ▾ Background jobs ] 0 │ ┃ ↻ — │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Usage
╭──────────────────────────────────────────────────────────╮ │ (none) │ │ Cannot read quota: SyntaxError: JSON Parse error: │ │ Unexpected EOF; SyntaxError: JSON Parse error: │ │ Unexpected EOF │ ╰──────────────────────────────────────────────────────────╯ [ ▾ Quota ] ╭──────────────────────────────────────────────────────────╮ │ (none) │ │ │ ╰──────────────────────────────────────────────────────────╯ [ ▾ Claude sessions ] 0 busy / 0 ╭──────────────────────────────────────────────────────────╮ │ (none) │ ╰──────────────────────────────────────────────────────────╯ [ ▾ Background jobs ] 0 ↻ —
README

<img src="docs/readme-logo.png" alt="usage logo" width="128">

usage

Your Claude Code, Codex, Antigravity and Grok CLI quota, always on screen.

usage puts your 5-hour and weekly limits in the macOS menu bar or Windows system tray, colored from green to red. Hitting the limit halfway through a long refactor is a bad way to find out you were running low. Now you see it coming. There's nothing to run and no page to open.

繁體中文 · 简体中文 · English · 日本語 · 한국어 &nbsp;|&nbsp; Discussions &nbsp;|&nbsp; Landing page

GitHub stars CI Latest Release PyPI Python Platform License: AGPL-3.0 OpenSSF Best Practices

<img src="docs/showcase-v3.en.png" alt="usage — Claude Code, Codex, and Antigravity quota pinned to the macOS menu bar" width="820">

  • Every quota at a glance: Claude Code, Codex, and Antigravity session and weekly limits with reset countdowns, plus Grok CLI's weekly credit.
  • Reads what's already on your machine: Claude Code, Codex, and Grok CLI numbers come from local logs. Antigravity quota comes from Google's official endpoint, using the sign-in its CLI already stores.
  • Warns before tokens go to waste: The Claude Code status line flags a bloating context window and a cold prompt cache. A banner shows Claude or Codex outages.
  • Helpers inside Claude Code: A side pane, shorter replies, a progress hand-off for new sessions, and quota-aware planning. All optional.
  • Reports and 16 themes: HTML reports of token trends and cost, and 16 panel themes to pick from.

Install

Runs on macOS 12 or newer and Windows 10/11.

macOS, with Homebrew (recommended):

brew install --cask aqua5230/usage/usage

It lands in your Applications folder, and brew upgrade --cask usage keeps it current. Prefer a direct download? Get usage.app.zip from the latest release, unzip it, and drag usage.app into Applications.

First launch on macOS: if macOS 15 or later blocks it, open System Settings → Privacy & Security, scroll down, and click Open Anyway. On macOS 14 or earlier, right-click usage.app in Finder → Open once. Then click the menu bar icon.

Windows: download usage-windows.zip from the latest release, unzip it, and run usage.exe. No installer is needed. If SmartScreen shows Windows protected your PC, click More info → Run anyway. See Windows Support.

Terminal only, any OS (Linux included): uvx usage-cli opens the terminal interface with nothing to install; uv prepares Python 3.13 by itself. For a persistent usage command, run uv tool install usage-cli. On Linux, usage setup installs the Claude Code status line too. This path has no menu bar or tray app.

usage needs data from at least one of Claude Code, Codex, Antigravity, or Grok CLI, or a running Claude Desktop app.

First Launch: Set Up the Status Line

If you've used Codex, usage picks up its history automatically. For Claude Code, click the "Set Up Status Line" button in the app popover to install the sync hook. Restart the relevant tool afterward (on macOS, fully Cmd+Q Claude Code and re-open it; on Windows, restart your terminal or start a new session).

The same button also sets up a status line for the Antigravity CLI and for Grok CLI when they are installed on your machine, and does nothing at all when they aren't. Any status line you configured there yourself is backed up first and restored when you turn the switch off.

Once set up, the bottom of the Claude Code window will show a status line like this:

<img src="docs/statusline.en.gif" alt="Claude Code statusLine display (English)" width="900">

What You Get

On Screen

  • Menu bar monitor: Quota color-coded from green to red. Click for the full session, weekly, and per-project breakdown.
  • Antigravity card: Shows the Gemini pool by default. Tap the Gemini ⇄ tag to switch to the separate Claude / GPT pool; the choice is remembered.
  • Grok CLI card: The weekly credit percentage from Grok CLI's local debug log. Its tokens also count toward cost and project totals.
  • Muse Code spending: Its tokens and cost count toward today's cost, project totals, reports, and the CLI. Muse keeps no quota data locally, so it has no card.
  • Outage alerts: An orange-red banner when Claude Code, Claude API, or Codex API is down or degraded, read from their public status pages.
  • Context and cache warnings: The status line nudges you to /clear or /compact before the context window bloats, and says why the prompt cache missed.
  • Hide what you don't use: Hide the Claude Code, Codex, Grok CLI, or Antigravity section from the menu bar and panels in one click.

Inside Claude Code

  • Progress Concierge: A new session starts with your last request, uncommitted changes, and unfinished todos already handed to the AI. No /resume, no recap. Off by default.
  • Token Saver: Asks Claude Code and Codex for shorter, plainer replies while keeping code and error messages byte-exact. In an A/B test on real sessions, late replies stayed ~40% shorter instead of drifting 84% longer.
  • Side pane: Quotas, other conversations, and background jobs next to your work. See the side pane.
  • Beginner Mode: Explains up to three technical terms from each answer in one plain line, then brings them back for review. Off by default; uses a little Claude quota.
  • Quota-Aware Mode: When a quota runs low, Claude tells you before a big task that would use it, and lets you do a smaller part or wait for the reset. Off by default; makes no extra model calls.
  • Auto-start 5-hour session: Right after a 5-hour quota resets, sends each tool one tiny message so the next window starts counting right away. Off by default.
  • Token-waste health check: A daily scan of your logs for repeated file reads and noisy output. Say "show me" and the AI walks you through fixes.

Reports and More

  • HTML reports: Daily and weekly token trends, project rankings, cost, and a Year in Review with a contribution heatmap. Export .html, .csv, or .png fully offline, with optional project-name masking.
  • Terminal integration: usage status --json hands your Claude Code, Codex, Antigravity, and Grok quota to Starship, tmux, or your own scripts. Ready-made snippets.
  • AI Update Daily: A daily public page of Claude Code, Codex, and Antigravity changes, with plain-language summaries in five languages.
  • Your layout: Drag the panel anywhere, drag quota cards to reorder them, and switch among 16 themes. The UI follows your system language: Traditional Chinese, Simplified Chinese, English, Japanese, or Korean.
  • Context colors: The context figure turns yellow at 50% or 200K tokens and red at 80% or 400K tokens, whichever comes first. The nudge appears at 70%, or earlier when the context is filling fast. When the color changes, the status line shows the image count and the estimated share of files and command output.
  • Prompt cache: The hit rate needs Claude Code 2.1.251 or newer. For 10 minutes after a miss, the status line says why — the model changed, the tools changed, you sat idle past the 5-minute TTL, and so on; that needs 2.1.260 or newer. On older versions those parts don't appear.
  • Notifications: Opt in to system notifications for quota limits and recoveries.
  • Antigravity card: Refreshed every few minutes. It appears in every theme except World Cup 2026, which stays a two-team HUD.
  • Outage alerts: Antigravity isn't covered; it has no public status page.
  • Token-waste health check: It also flags polluter directories and noisy Bash output.
  • Linux: CI verifies usage setup on Ubuntu.
  • Progress Concierge: When you /resume a conversation that sat long enough for its cache to expire, it warns how many tokens the next message will re-send and suggests /compact first.
  • Beginner Mode (macOS and Windows): Terms appear above the prompt in your UI language. Press 9 to mark them understood; skipped terms come back after 1, 3, then 7 days. Understood terms return as a one-question multiple-choice quiz 7, 21, then 60 days later, at most one a day, and a wrong answer puts the term back in the hints. /terms opens your term history. Picking terms asks Claude Haiku through your Claude Code, and pauses while your 5-hour quota is at 90% or more.
  • Quota-Aware Mode (macOS and Windows): Claude Code gets one line with what is left and when it resets when a 5-hour quota passes 80%, 90%, or 95%, or a weekly quota passes 95%. It covers Claude Code, Codex, and Antigravity, says each level once per conversation, and treats each quota and model group separately, so a used-up one only matters for work that runs on it.
  • Auto-start 5-hour session: Claude gets Haiku, Antigravity gets Gemini 3.8 Flash Low, and Codex gets its cheapest model. The quota used is negligible. Checking your quota never sends a message; only this switch does.
  • Panel: It stays put when another app takes focus; a second click on the menu bar icon, or Escape, closes it. Card order is shared across every theme with quota cards (all except World Cup 2026) and survives restarts.
  • HTML reports: A "What you worked on" section lists the names Claude Code gave your recent conversations, and masking covers those titles too.
  • AI Update Daily: Unreviewed items show the original source text. The full history is kept.
  • Release notes: The first launch after an update shows what changed in that version, once, in your UI language. Fresh installs skip it.

Claude Code Side Pane

See your quota, other conversations, and background jobs without leaving Claude Code. Available on macOS and Windows.

What you’ll see

  • Quotas: Your 5-hour and weekly limits.
  • Claude conversations: Conversations waiting for your permission or MCP input are marked in yellow.
  • Conversation notifications: A notification, starting with the project name, appears when another Claude conversation finishes or starts waiting for you.
  • Latest reply: Each conversation row shows the latest assistant reply on its second line.
  • Background jobs: Includes subagents started by Claude with the Agent tool and their status.

How to enable

  1. In the usage menu bar menu (macOS) or system-tray menu (Windows), open the Claude Code submenu and check Side pane.
  2. Open a new conversation or run /reload-plugins.
  3. At terminal widths ≥144 columns, the pane opens on the right automatically. In narrower terminals, enter /usage-dash.
  • Requires a recent Claude Code with mod support; tested with 2.1.289.
  • The pane docks on the right only in Claude Code's fullscreen layout; otherwise it appears above the prompt. On Windows, enabling the pane turns the fullscreen layout on as well, and turning the pane off switches it back.
  • The built-in macOS Terminal supports only 256 colors and may show a gray background. Select an ANSI dark theme in /config.
  • When the usage app starts, it automatically updates an enabled pane to the bundled version.

Privacy & Data Sources

  • Local logs: Claude Code, Codex, Grok CLI, and Muse Code numbers are read from log files on your machine. Their contents are never uploaded.
  • Claude Desktop: Without the Claude Code CLI or a status line, keep Claude Desktop open and usage reads its local plan-usage history. No cookies, login tokens, or API calls are needed. Reset times appear only when its local cache confirms them; they are never estimated.
  • Antigravity, only if you use it, needs network access: quota is fetched from Google's official quota endpoint with the OAuth credential the Antigravity CLI already stored after sign-in — read from macOS Keychain, Windows Credential Manager, or a local token file depending on CLI version. usage never writes that credential back and keeps any refreshed access token in memory only; the call itself reads quota metadata.
  • Other background network activity: Public Claude and Codex status pages to flag outages, a public model-pricing table to estimate cost (built-in prices are used offline), and an occasional GitHub check for a new version.
  • Beginner Mode, only if you turn it on, sends Claude Code's latest answer to Claude Haiku through your own Claude Code sign-in to pick terms. Your term list stays in ~/.usage/glossary.json.

When Claude Code quota files are unavailable, usage reads Claude Desktop's local plan-usage-history.json, including Microsoft Store installations on Windows. When a recent response in its local Chromium block-file HTTP cache matches the organization, usage also reads the exact session and weekly reset times. Newer cached observations take precedence over throttled history samples; older cache must match the percentages. Missing, unsupported, expired, or inconsistent cache data leaves the countdown unknown.

Desktop samples normally update every 5–15 minutes. The panel shows the observation age, marks it stale after 30 minutes, and stops displaying it after two hours. The most recent organization sample is used; custom desktop profiles are not discovered. These quota caches contain no per-project token counts. Desktop sessions that also write compatible Claude Code logs under ~/.claude/projects/ are counted by the existing project/token reports.

Windows Support

Windows has the full core experience: the system-tray UI with the same 16 themes as macOS, the Claude Code status-line hook, and Codex history parsing all work natively. The tray UI requires Microsoft Edge WebView2 Runtime, which is normally included with Windows 10 and 11.

The system-tray icon shows the remaining session quota percentage for Claude or Codex. Choose Tray Display Source → Claude Code / Codex in the right-click menu or panel menu; the change applies immediately and survives restarts (default: Claude). If Codex has no session window, the icon uses its weekly quota instead and the tooltip identifies that window. Missing quota data shows --. The tooltip summarizes both tools, with the selected source first. Left-click opens the quota themes in WebView2. Right-click also provides Reset Panel Position and Quit; panel switching, refresh, launch at login, and update checks are in the panel menu.

Enable Show Taskbar Quota in either menu for a transparent Codex: 92% label inside the taskbar, immediately left of the notification area. It follows taskbar position, scaling, and light/dark theme, and hides during fullscreen use or taskbar auto-hide. Click the label to open the panel. If buttons leave insufficient space, it moves just outside the taskbar. The normal app icon remains as a menu entry point; the label follows the selected source and quota window. Right-click the label to open the same menu as the tray icon.

The panel opens at the bottom-right of the working area rather than next to the tray icon, and update prompts use a system Yes/No dialog.

Code signing policy

Free code signing provided by SignPath.io, certificate by SignPath Foundation.

Team roles:

Privacy policy: this program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. See Privacy & Data Sources for the network calls usage makes on your behalf and how to avoid them.

Theme Gallery

Switch between 16 visual themes directly from the UI:

<img src="docs/classic.en.png" width="24%" alt="Classic theme" /> <img src="docs/matrix.en.png" width="24%" alt="Matrix theme" /> <img src="docs/win95.en.png" width="24%" alt="Windows 95 theme" /> <img src="docs/newspaper.en.png" width="24%" alt="Newspaper theme" />

<img src="docs/cloud_observation.en.png" width="24%" alt="Cloud Observation theme" /> <img src="docs/aquarium.en.png" width="24%" alt="Midnight Aquarium theme" /> <img src="docs/prism_arcade.en.png" width="24%" alt="Prism Arcade theme" /> <img src="docs/stained_glass.en.png" width="24%" alt="Stained Glass theme" /> <img src="docs/origami.en.png" width="24%" alt="Origami theme" /> <img src="docs/black_hole.en.png" width="24%" alt="Black Hole theme" /> <img src="docs/world_cup.en.png" width="24%" alt="World Cup 2026 theme" /> <img src="docs/lepidoptera.en.png" width="24%" alt="Lepidoptera theme" /> <img src="docs/migration.en.png" width="24%" alt="Migration theme" /> <img src="docs/catppuccin.en.png" width="24%" alt="Catppuccin theme" /> <img src="docs/sketchbook.en.png" width="24%" alt="Sketchbook theme" /> <img src="docs/heart_monitor.en.png" width="24%" alt="Heart Monitor theme" />

Troubleshooting

If the menu bar shows --, it's usually not broken — there's just no local data yet.

SymptomLikely causeFix
Menu bar shows --No data yet, or Claude Code hook not refreshedRun one Codex conversation. For Claude Code, click "Set Up Status Line" (from source: python3 main.py --setup)
ImportError from main.py inside usage.appThe bundled main.py needs the bundle's own interpreter and cannot be run by handDon't run that copy. Click "Set Up Status Line" in the app, or clone the repo to run from source
Accidentally hit "Quit"Process terminatedRelaunch usage.app from Spotlight or Applications. (launchctl start com.lollapalooza.usage only works if you enabled Launch at Login.)
Status says "N minutes stale"Claude Code isn't runningOpen Claude Code and let it run
Codex section is emptyNo Codex history foundRun a Codex conversation to generate logs
Today's cost shows $0.00Model pricing missingDelete ~/.usage/pricing_cache.json or check USAGE_DEBUG=1
Antigravity card is missingAntigravity CLI not installed or not signed inInstall and sign in to the Antigravity CLI; the card appears automatically once a background quota fetch succeeds
App won't openmacOS Gatekeeper blocked itSee First launch on macOS
Windows shows "Windows protected your PC"SmartScreen doesn't recognize the download yetClick More info → Run anyway

Comparison

FeatureusageccusageTokenTracker
Always on screen✅—✅
macOS menu bar & Windows system tray✅—macOS only
Claude Code & Codex usage✅✅✅
Antigravity usage (Gemini and Claude / GPT)✅——
Grok CLI usage✅——
Muse Code token spend✅——
Claude Code & Codex service-status alerts✅——
HTML deep reports & UI✅✅—
Claude Code helpers (Token Saver, Progress Concierge, Beginner and Quota-Aware modes, health check)✅——
AI Update Daily✅——
Open-source licenseAGPL-3.0MIT—

When usage Isn't the Right Fit

  • You only live in the terminal and don't want another menu bar icon running in the background — a one-off CLI check fits better.
  • You don't use Claude Code, Codex, Antigravity, Grok CLI, or Claude Desktop — there's no usage data for usage to read.
  • You want a menu bar on Linux. Only macOS and Windows have one today, though the terminal interface (uvx usage-cli) runs on Linux.

Development

Building from source, configuring custom agents, or running the terminal TUI? See the development docs.

License

Licensed under AGPL-3.0-only (see LICENSE). If you fork or redistribute a modified version, please credit the original author and link back to: https://github.com/aqua5230/usage

Source 8 files
hooks/register.tsx 392 lines
1import { t, configure, statusArgv } from './strings'
2import { atom, read, update } from 'claude-code'
3import type { Hook, Register } from 'claude-code'
4import type { Run, Session } from '../types'
5import { matchAgent, isShellBackgrounded, parseNotifications, backgroundTasks, liveBgTasks, STALE_MS } from './parse'
6import { parseQuota, hideAgents, byTightest, showsUnavailable, effectivePercent, dockQuotaLine, staleAge, refreshedAgo } from './quota'
7import { compactLines } from './compact'
8import { parseLiveSession, liveSessions, tasklistPids, toSession, newest, isBusy, waitingText, settleNotifications, sessionMark } from './sessions'
9import { contextPercent, contextColor, parseContext, staleContext, completedAgo, isWaiting, visibleAgents, backgroundCount, visibleRuns } from './display'
10const PANE = 'usage-dash'
11const quotas = atom({ plugin: 'usage-dash', key: 'quotas' } as const, { agents: {} })
12const updated = atom({ plugin: 'usage-dash', key: 'updated' } as const, null)
13const quotaError = atom({ plugin: 'usage-dash', key: 'quotaError' } as const, '')
14const sessions = atom({ plugin: 'usage-dash', key: 'sessions' } as const, [])
15const pendingToasts = atom({ plugin: 'usage-dash', key: 'pendingToasts' } as const, [])
16const sessionError = atom({ plugin: 'usage-dash', key: 'sessionError' } as const, '')
17const agents = atom({ plugin: 'usage-dash', key: 'agents' } as const, [])
18const runs = atom({ plugin: 'usage-dash', key: 'runs' } as const, [])
19const bgTasks = atom({ plugin: 'usage-dash', key: 'bgTasks' } as const, [])
20const collapsed = atom({ plugin: 'usage-dash', key: 'collapsed' } as const, { quota: false, sessions: false, runs: false })
21const more = atom({ plugin: 'usage-dash', key: 'more' } as const, false)
22const moreRuns = atom({ plugin: 'usage-dash', key: 'moreRuns' } as const, false)
23type Dollar = Parameters<Hook<'session.start'>>[0]
24const KEEP_DONE_MS = 300000
25function isVisible(r: Run, now: number): boolean { return r.end === null ? now - r.start < STALE_MS : now - r.end < KEEP_DONE_MS }
26async function getHome($: Dollar) { return (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) }
27function clearLater($: Dollar) {
28  $.clock.after(KEEP_DONE_MS + 1000, async () => {
29    const now = await $.clock.now()
30    await update($, runs, list => list.filter(r => isVisible(r, now)))
31    await reportContext($, null)
32  })
33}
34async function finish($: Dollar, text: string) {
35  const done = parseNotifications(text)
36  if (!done.length) return
37  const end = await $.clock.now()
38  await update($, runs, list => list.map(r => {
39    const d = done.find(x => x.toolUseId === r.id)
40    return d && r.end === null ? { ...r, end, status: d.status } : r
41  }))
42  await update($, bgTasks, list => liveBgTasks(list.filter(task => !done.some(d => d.taskId === task.id)), end))
43  await reportContext($, null)
44  clearLater($)
45}
46async function refreshQuota($: Dollar) {
47  const home = await getHome($)
48  const failures: string[] = []
49  const commands = statusArgv ? [statusArgv] : ['usage', ...(home ? [`${home}/.local/bin/usage`] : [])].map(executable => [executable, 'status', '--json'])
50  for (const argv of commands) {
51    try {
52      const result = await $.process.run(argv, { timeoutMs: 10000 })
53      if (result.exitCode !== 0) throw new Error(`${argv[0]}: ${result.stderr || t('exit_code', { code: result.exitCode })}`)
54      const parsed = parseQuota(result.stdout)
55      let preferences = ''
56      try { if (home) preferences = await $.fs.read(`${home}/.claude/usage-preferences.json`) } catch { /* missing preferences are normal */ }
57      const data = hideAgents(parsed, preferences)
58      const now = await $.clock.now()
59      await update($, quotas, () => data)
60      await update($, updated, () => now)
61      await update($, quotaError, () => '')
62      return
63    } catch (error) { failures.push(String(error)) }
64  }
65  await update($, quotaError, () => t('quota_error', { error: failures.join('; ') }))
66}
67async function reportContext($: Dollar, waiting: boolean | null = false) {
68  try {
69    const home = await getHome($), sessionId = await $.session.id()
70    if (!home || !/^[A-Za-z0-9_-]+$/.test(sessionId)) return
71    const directory = `${home}/.usage/claude-pane/live`
72    const path = `${directory}/${sessionId}.json`
73    const now = await $.clock.now()
74    const jobs = backgroundCount((await read($, runs)).filter(r => isVisible(r, now)), visibleAgents(await read($, agents)), liveBgTasks(await read($, bgTasks), now))
75    let previous: ReturnType<typeof parseContext> = null
76    try { previous = parseContext(await $.fs.read(path)) } catch { /* no previous context is normal */ }
77    if (waiting === null && previous?.jobs === jobs) return
78    let percent = previous?.percent, tokens = previous?.tokens
79    try {
80      const context = (await $.session.usage()).context
81      percent = contextPercent(context.percent) ?? percent
82      tokens = context.tokens !== undefined && Number.isInteger(context.tokens) && context.tokens >= 0 ? context.tokens : tokens
83    } catch { /* waiting can be reported without usage */ }
84    await $.fs.write(path, JSON.stringify({ sessionId, percent, tokens, waiting: waiting ?? previous?.waiting ?? false, jobs, updatedAt: now }))
85  } catch { /* live reporting is best effort, as requested */ }
86}
87async function readContexts($: Dollar, directory: string, rows: Session[]) {
88  const openIds = new Set(rows.map(row => row.id)), now = await $.clock.now()
89  try {
90    for (const file of await $.fs.list(directory)) {
91      if (file.kind !== 'file' || file.isLink || !/^[A-Za-z0-9_-]+\.json$/.test(file.name)) continue
92      const sessionId = file.name.slice(0, -5), path = `${directory}/${file.name}`
93      try {
94        const context = parseContext(await $.fs.read(path))
95        if (!context || context.sessionId !== sessionId) continue
96        const row = rows.find(row => row.id === sessionId)
97        if (row) {
98          row.contextPercent = context.percent
99          row.contextTokens = context.tokens
100          row.jobs = context.jobs
101          if (context.waiting !== undefined) {
102            row.waiting = context.waiting
103            row.waitingUpdatedAt = context.updatedAt
104          }
105        }
106        if (staleContext(sessionId, Math.max(file.mtimeMs, context.updatedAt), openIds, now)) {
107          if ((await $.env.get('OS')) === 'Windows_NT') await $.process.run(['cmd', '/d', '/c', 'del', '/f', '/q', path.replaceAll('/', '\\')])
108          else await $.process.run(['rm', '-f', path])
109        }
110      } catch { /* unavailable live records do not affect the session list */ }
111    }
112  } catch { /* a missing live directory is normal before the first report */ }
113}
114async function refreshSessions($: Dollar) {
115  const failures: string[] = []
116  try {
117    const home = await getHome($)
118    if (!home) throw new Error(t('home_missing'))
119    const directory = `${home}/.claude/sessions`
120    const candidates = []
121    for (const file of await $.fs.list(directory)) {
122      if (!file.name.endsWith('.json')) continue
123      try {
124        const row = parseLiveSession(await $.fs.read(`${directory}/${file.name}`))
125        if (row) candidates.push(row)
126      } catch (error) { failures.push(`${file.name}:${String(error)}`) }
127    }
128    let psOutput = ''
129    if (candidates.length) {
130      try {
131        if ((await $.env.get('OS')) === 'Windows_NT') {
132          const result = await $.process.run(['tasklist', '/FO', 'CSV', '/NH'])
133          if (result.exitCode === 0) psOutput = tasklistPids(result.stdout)
134        } else {
135          const result = await $.process.run(['ps', '-o', 'pid=', '-p', candidates.map(row => row.pid).join(',')])
136          if (result.exitCode === 0) psOutput = result.stdout
137        }
138      } catch { /* ps failure means no live processes */ }
139    }
140    const rows: Session[] = []
141    for (const row of liveSessions(candidates, psOutput)) {
142      let transcript = ''
143      try {
144        if ((await $.env.get('OS')) === 'Windows_NT') {
145          let path = ''
146          for (const dir of await $.fs.list(`${home}/.claude/projects`)) {
147            const candidate = `${home}/.claude/projects/${dir.name}/${row.sessionId}.jsonl`
148            if (dir.kind === 'dir' && await $.fs.exists(candidate)) { path = candidate; break }
149          }
150          if (path) {
151            if ((await $.fs.stat(path)).size <= 4 * 1024 * 1024) {
152              transcript = (await $.fs.read(path)).slice(-262144)
153            } else {
154              const result = await $.process.run(['findstr', '/L', '/C:ai-title', '/C:last-prompt', path.replaceAll('/', '\\')])
155              if (result.exitCode === 0) transcript = result.stdout
156            }
157          }
158        } else {
159          const found = await $.process.run(['/bin/sh', '-c', 'ls "$1"/*/"$2".jsonl 2>/dev/null | head -1', 'sh', `${home}/.claude/projects`, row.sessionId])
160          const path = found.exitCode === 0 ? found.stdout.trim() : ''
161          if (path) {
162            const result = await $.process.run(['tail', '-c', '262144', path])
163            if (result.exitCode !== 0) throw new Error(result.stderr || t('exit_code', { code: result.exitCode }))
164            transcript = result.stdout
165          }
166        }
167      } catch (error) { failures.push(`${row.sessionId}:${String(error)}`) }
168      rows.push(toSession(row, transcript))
169    }
170    await readContexts($, `${home}/.usage/claude-pane/live`, rows)
171    const previous = await read($, sessions), currentId = await $.session.id()
172    const settled = settleNotifications(await read($, pendingToasts), previous, rows, currentId)
173    await update($, sessions, () => newest(rows))
174    await update($, pendingToasts, () => settled.pending)
175    for (const { session, kind } of settled.toast) {
176      const title = session.title.length > 40 ? `${session.title.slice(0,40)}…` : session.title
177      $.ui.toast(t(`session_${kind}`, { title: `${session.source} · ${title}` }), { timeoutMs: 8000 })
178    }
179  } catch (error) { failures.push(String(error)) }
180  await update($, sessionError, () => failures.length ? t('session_error', { error: failures.join('; ') }) : '')
181}
182async function refreshAgents($: Dollar) {
183  let list: Awaited<ReturnType<Dollar['agent']['list']>> = []
184  try { list = await $.agent.list() } catch { /* unavailable agents are an empty list */ }
185  const now = await $.clock.now()
186  await update($, bgTasks, tasks => liveBgTasks(tasks, now))
187  await update($, agents, () => visibleAgents(list))
188  await reportContext($, null)
189}
190async function refreshSessionLists($: Dollar) {
191  await refreshAgents($)
192  await refreshSessions($)
193}
194let quotaTimer: { cancel(): void } | undefined
195let sessionTimer: { cancel(): void } | undefined
196let redrawTimer: { cancel(): void } | undefined
197export const register: Register = (on) => {
198  on('session.start', async ($, e, next) => {
199    configure()
200    try {
201      const root = $.plugin.root.replace(/[\\/]\.claude-plugin[\\/]?$/, '')
202      const path = `${root}/usage-pane.json`
203      if (await $.fs.exists(path)) {
204        const value = JSON.parse(await $.fs.read(path))
205        if (!value || typeof value.strings !== 'object' || value.strings === null ||
206            Array.isArray(value.strings) || !Object.values(value.strings).every(v => typeof v === 'string') ||
207            !Array.isArray(value.status_argv) || !value.status_argv.length ||
208            !value.status_argv.every((v: unknown) => typeof v === 'string' && v.length)) {
209          throw new Error(t('invalid_sidecar'))
210        }
211        configure(value)
212      }
213    } catch (error) {
214      await $.ui.log(t('sidecar_error', { error: String(error) }))
215    }
216    await $.command.register({ name: PANE, description: t('description') })
217    void $.ui.open({ id: PANE, title: t('title') })
218    quotaTimer?.cancel(); sessionTimer?.cancel(); redrawTimer?.cancel()
219    quotaTimer = $.clock.every(60000, () => refreshQuota($))
220    sessionTimer = $.clock.every(15000, () => refreshSessionLists($))
221    redrawTimer = $.clock.every(1000, () => $.ui.invalidate('ui.render'))
222    await reportContext($)
223    await refreshQuota($)
224    await refreshSessionLists($)
225    return next(e)
226  })
227  on('turn.complete', async ($, e, next) => {
228    const result = await next(e)
229    await reportContext($)
230    return result
231  })
232  on('classic.Stop', async ($, e, next) => {
233    const result = await next(e)
234    const now = await $.clock.now()
235    await update($, bgTasks, list => backgroundTasks(e.background_tasks ?? [], list, now))
236    await reportContext($, null)
237    return result
238  })
239  on('classic.PermissionRequest', async ($, e, next) => {
240    await reportContext($, true)
241    return next(e)
242  })
243  on('classic.Notification', async ($, e, next) => {
244    if (e.notification_type === 'permission_prompt' || e.notification_type === 'elicitation_dialog') await reportContext($, true)
245    return next(e)
246  })
247  on('classic.Elicitation', async ($, e, next) => {
248    await reportContext($, true)
249    return next(e)
250  })
251  on('classic.PostToolUse', async ($, e, next) => {
252    await reportContext($)
253    return next(e)
254  })
255  on('classic.PermissionDenied', async ($, e, next) => {
256    await reportContext($)
257    return next(e)
258  })
259  on('classic.ElicitationResult', async ($, e, next) => {
260    await reportContext($)
261    return next(e)
262  })
263  on('command.run', { command: PANE }, async $ => {
264    await $.ui.open({ id: PANE, title: t('title') })
265    return { text: t('opened') }
266  })
267  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
268    const agent = matchAgent(e.command)
269    if (agent === null) return next(e)
270    const start = await $.clock.now()
271    const run: Run = { id: e.tool_use_id ?? `run-${start}`, agent, label: (e.description ?? '').slice(0,24), start, end: null, status: 'running' }
272    await update($, runs, list => [...list.filter(r => isVisible(r,start)), run])
273    await reportContext($, null)
274    const ran = await next(e)
275    if (ran.deny !== undefined) {
276      await update($, runs, list => list.filter(r => r.id !== run.id))
277      await reportContext($, null)
278      return ran
279    }
280    const result = ran.result as { backgroundTaskId?: string } | undefined
281    if (result?.backgroundTaskId === undefined) {
282      const end = await $.clock.now()
283      const status = isShellBackgrounded(e.command) ? 'launched' : ran.isError ? 'failed' : 'completed'
284      await update($, runs, list => list.map(r => r.id === run.id ? { ...r, end, status } : r))
285      await reportContext($, null)
286      clearLater($)
287    }
288    return ran
289  })
290  on('prompt.submit', async ($, e, next) => {
291    await reportContext($)
292    if (e.origin.kind === 'task-notification') await finish($, e.text)
293    return next(e)
294  })
295  on('session.receive', async ($, e, next) => {
296    if (e.origin.kind === 'task-notification') await finish($, e.text)
297    return next(e)
298  })
299  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
300    const { Box, Text, Button } = $.ui.resolve(e)
301    const now = await $.clock.now()
302    const data = await read($, quotas), list = await read($, sessions), fold = await read($, collapsed)
303    const shown = (await read($, runs)).filter(r => isVisible(r,now))
304    const expanded = await read($, more), last = await read($, updated), runsExpanded = await read($, moreRuns)
305    const qe = await read($, quotaError), se = await read($, sessionError)
306    const current = await $.session.id()
307    const agentList = visibleAgents(await read($, agents))
308    const tasks = liveBgTasks(await read($, bgTasks), now)
309    const jobs = backgroundCount(shown, agentList, tasks)
310    const running = shown.filter(r => r.end === null).length + tasks.length
311    if (e.props.placement === 'inline') {
312      return <Box flexDirection="column">
313        {compactLines(data,list,running,now,last).map((line,i) => <Text key={String(i)} wrap="wrap">
314          {line.length ? line.map((part,j) => <Text key={String(j)} color={part.color} dimColor={!part.color} bold={part.bold}>{part.text}</Text>) : <Text dimColor>{' '}</Text>}
315        </Text>)}
316      </Box>
317    }
318    return <Box flexDirection="column">
319      <Box flexDirection="column">
320      <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
321      {!fold.quota && <Box flexDirection="column">
322        {byTightest(data, ['claude-code','codex','antigravity','grok'], now, last === null ? 0 : (now-last)/1000).map(key => {
323          const agent = data.agents[key]
324          if (!agent || (!agent.available && !showsUnavailable(key, agent.reason))) return null
325          const name = key === 'claude-code' ? 'Claude' : key === 'codex' ? 'Codex' : key === 'antigravity' ? 'agy' : 'Grok'
326          if (!agent.available) return <Text key={key}><Text color={key === 'claude-code' ? '#d97757' : key === 'codex' ? '#10a37f' : key === 'antigravity' ? '#4285f4' : 'white'} bold={key === 'grok'}>{name.padEnd(8)}</Text><Text dimColor>{t(`unavailable_${agent.reason ?? 'no_data'}`)}</Text></Text>
327          const age = staleAge(agent.age_seconds ?? 0, now, last)
328          const groups = agent.groups ?? [{ name: '', five_hour: agent.five_hour, seven_day: agent.seven_day }]
329          const rows = groups.flatMap(group => [
330            ...(groups.length > 1 && group.name ? [[group.name === 'GEMINI MODELS' ? 'Gemini' : group.name === 'CLAUDE AND GPT MODELS' ? 'Claude / GPT' : group.name, undefined] as const] : []),
331            ...([[t('five_hour'),group.five_hour],[t('week'),group.seven_day],[t('period'),agent.period]] as const).filter(([,window]) => window),
332          ])
333          return <Box key={key} flexDirection="column">
334            {rows.map(([label,window],i) => {
335              const line = window ? dockQuotaLine(label,window,now,last === null ? 0 : (now-last)/1000,e.props.bodyColumns) : null
336              // Under 50% stays gray so only the windows worth watching carry color.
337              const color = window && effectivePercent(window,now,last === null ? 0 : (now-last)/1000) >= 50 ? line?.color : undefined
338              return <Text key={String(i)}><Text color={key === 'claude-code' ? '#d97757' : key === 'codex' ? '#10a37f' : key === 'antigravity' ? '#4285f4' : 'white'} bold={key === 'grok'}>{i === 0 ? `${name.padEnd(8)}` : '        '}</Text><Text dimColor>{line ? line.label : label}</Text>{line && <Text color={color} dimColor={!color}>{line.filled}</Text>}{line && <Text dimColor>{line.empty}</Text>}{line && ' '}{line && <Text color={color} dimColor={!color}>{line.percent}</Text>}{line && <Text dimColor>{line.countdown}</Text>}{i === 0 && age && <Text color="yellow">{age}</Text>}</Text>
339            })}
340          </Box>
341        })}
342        {!Object.keys(data.agents).length && <Text dimColor>{t('none')}</Text>}
343        {qe && <Text color="red">{qe}</Text>}
344      </Box>}
345      </Box>
346      <Box position="absolute" top={0} left={2} right={2} justifyContent="space-between"><Button key="quota" plain label={`[ ${fold.quota ? '▸' : '▾'} ${t('quota')} ]`} onPress={() => update($, collapsed, v => ({ ...v, quota: !v.quota }))} /></Box>
347      </Box>
348      <Box flexDirection="column">
349      <Box flexDirection="column" borderStyle="round" borderColor="blue" paddingX={1}>
350      {!fold.sessions && <Box flexDirection="column">
351        {list.slice(0,expanded ? list.length : 4).map(s => {
352          const waiting = s.status === 'waiting' || isWaiting({ waiting: s.waiting, updatedAt: s.waitingUpdatedAt ?? 0 }, now), mark = sessionMark(s, waiting, now)
353          return <Box key={s.id} flexDirection="column">
354            <Box justifyContent="space-between"><Box flexShrink={1}><Text wrap="truncate-end"><Text color={mark.color} dimColor={!mark.color}>{mark.text}</Text><Text dimColor>{` ${s.source}${s.source ? ' ' : ''}`}</Text>{s.id === current && <Text color="cyan">{t('here')}</Text>}{s.title || t('untitled')}</Text></Box>{s.contextPercent !== undefined && <Box flexShrink={0} marginLeft={1}><Text color={contextColor(s.contextPercent, s.contextTokens)} dimColor={contextColor(s.contextPercent, s.contextTokens) === undefined}>{`${s.contextPercent}%`}</Text></Box>}</Box>
355            {(waiting || s.preview) && <Box justifyContent="space-between"><Box flexShrink={1}><Text wrap="truncate-end">{'    '}{waiting && <Text color="yellow">{waitingText(s.waitingFor)}</Text>}{s.preview && <Text dimColor>{`${waiting ? ' · ' : ''}${s.preview}`}</Text>}</Text></Box></Box>}
356          </Box>
357        })}
358        {!list.length && <Text dimColor>{t('none')}</Text>}
359        {list.length > 4 && <Button key="more" plain label={expanded ? `[ − ${t('less')} ]` : `[ + ${t('more', { count: list.length-4 })} ]`} onPress={() => update($, more, v => !v)} />}
360        {se && <Text color="red">{se}</Text>}
361      </Box>}
362      </Box>
363      <Box position="absolute" top={0} left={2} right={2} justifyContent="space-between">
364        <Button key="sessions" plain label={`[ ${fold.sessions ? '▸' : '▾'} ${t('sessions')} ]`} onPress={() => update($, collapsed, v => ({ ...v, sessions: !v.sessions }))} />
365        <Text>{' '}<Text dimColor>{t('busy_count', { count: list.filter(s => isBusy(s)).length, total: list.length })}</Text>{' '}</Text>
366      </Box>
367      </Box>
368      <Box flexDirection="column">
369      <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
370      {!fold.runs && <Box flexDirection="column">
371        {visibleRuns(shown, runsExpanded, tasks).rows.map(r => {
372          const done = r.end !== null
373          const seconds = Math.max(0, Math.floor(((r.end ?? now)-r.start)/1000))
374          const elapsed = t('elapsed', { minutes: Math.floor(seconds/60), seconds: String(seconds%60).padStart(2,'0') })
375          const prefix = done ? `${r.status === 'completed' ? '✓' : r.status === 'launched' ? '↗' : '✗'} ` : ''
376          return <Text key={r.id} dimColor={done} wrap="truncate-end"><Text color={done ? undefined : 'green'}>{`${prefix}${elapsed}`}</Text>{'  '}<Text color={done ? undefined : 'cyan'}>{r.agent}</Text>{`  ${r.label}`}<Text dimColor>{completedAgo(r,now)}</Text></Text>
377        })}
378        {agentList.map(agent => <Text key={`agent-${agent.id}`} wrap="truncate-end"><Text color={agent.status === 'running' ? 'green' : agent.status === 'waiting' ? 'yellow' : undefined} dimColor={agent.status === 'idle' || agent.status === 'pending'}>{agent.status === 'waiting' ? t('waiting') : t(`agent_${agent.status}`)}</Text>{'  '}<Text color="cyan">{agent.type}</Text>{`  ${agent.description}`}</Text>)}
379        {(runsExpanded || visibleRuns(shown, false, tasks).hiddenDone > 0) && <Button key="moreRuns" plain label={runsExpanded ? `[ − ${t('less')} ]` : `[ + ${t('more', { count: visibleRuns(shown, false, tasks).hiddenDone })} ]`} onPress={() => update($, moreRuns, v => !v)} />}
380        {!shown.length && !agentList.length && !tasks.length && <Text dimColor>{t('none')}</Text>}
381      </Box>}
382      </Box>
383      <Box position="absolute" top={0} left={2} right={2} justifyContent="space-between">
384        <Button key="runs" plain label={`[ ${fold.runs ? '▸' : '▾'} ${t('runs')} ]`} onPress={() => update($, collapsed, v => ({ ...v, runs: !v.runs }))} />
385        <Text>{' '}<Text color={jobs > 0 ? 'yellow' : undefined} dimColor={jobs === 0}>{jobs}</Text>{' '}</Text>
386      </Box>
387      </Box>
388      <Box justifyContent="flex-end"><Text dimColor>{refreshedAgo(now, last)}</Text></Box>
389    </Box>
390  })
391}
392
hooks/strings.ts 71 lines
1// English defaults mirror i18n.json; tests enforce parity.
2export const defaults: Record<string, string> = {
3  "claude_pane_quota": "Quota",
4  "claude_pane_five_hour": "5h",
5  "claude_pane_week": "Week",
6  "claude_pane_period": "Period",
7  "claude_pane_sessions": "Claude sessions",
8  "claude_pane_session_done": "Done: {title}",
9  "claude_pane_session_waiting": "Waiting for you: {title}",
10  "claude_pane_busy": "Busy",
11  "claude_pane_waiting": "waiting for you",
12  "claude_pane_unavailable_not_signed_in": "Not signed in · sign in once in its CLI",
13  "claude_pane_unavailable_no_data": "No data yet · shows up after first use",
14  "claude_pane_unavailable_error": "Couldn't read · retrying automatically",
15  "claude_pane_waiting_input": "needs your answer",
16  "claude_pane_waiting_dialog": "dialog open",
17  "claude_pane_waiting_sandbox": "sandbox permission",
18  "claude_pane_waiting_worker": "subagent request",
19  "claude_pane_waiting_goal": "confirm the goal",
20  "claude_pane_waiting_for": "waiting: {reason}",
21  "claude_pane_agent_running": "running",
22  "claude_pane_agent_idle": "idle",
23  "claude_pane_agent_pending": "pending",
24  "claude_pane_busy_count": "{count} busy / {total}",
25  "claude_pane_idle": "Idle {ago}",
26  "claude_pane_runs": "Background jobs",
27  "claude_pane_none": "(none)",
28  "claude_pane_untitled": "(untitled)",
29  "claude_pane_no_project": "(no project)",
30  "claude_pane_here": "(here)",
31  "claude_pane_more": "{count} more",
32  "claude_pane_less": "Show less",
33  "claude_pane_terminal": "Terminal",
34  "claude_pane_desktop": "Desktop",
35  "claude_pane_minutes": "{count}m",
36  "claude_pane_hours": "{count}h",
37  "claude_pane_days": "{count}d",
38  "claude_pane_seconds": "{count}s",
39  "claude_pane_minutes_ago": "{count}m ago",
40  "claude_pane_seconds_ago": "{count}s ago",
41  "claude_pane_just_now": "just now",
42  "claude_pane_failed": "failed",
43  "claude_pane_stopped": "stopped",
44  "claude_pane_completed": "completed",
45  "claude_pane_completed_ago": "{ago} · {status}",
46  "claude_pane_quota_invalid": "Quota JSON has no agents",
47  "claude_pane_exit_code": "exit code {code}",
48  "claude_pane_quota_error": "Cannot read quota: {error}",
49  "claude_pane_session_error": "Cannot read sessions: {error}",
50  "claude_pane_home_missing": "HOME is not set",
51  "claude_pane_description": "Open the quota, Claude sessions and background jobs pane",
52  "claude_pane_title": "Usage",
53  "claude_pane_opened": "Usage pane opened.",
54  "claude_pane_compact_summary": "Sessions {busy} busy / {total} · Background jobs ",
55  "claude_pane_sidecar_error": "Cannot read pane settings: {error}",
56  "claude_pane_invalid_sidecar": "Invalid usage-pane.json",
57  "claude_pane_remaining": "left",
58  "claude_pane_reset_done": "Reset",
59  "claude_pane_hours_ago": "{count}h ago",
60  "claude_pane_elapsed": "{minutes}m{seconds}s"
61}
62let strings = defaults
63export let statusArgv: string[] | undefined
64export function configure(value?: { strings?: Record<string, string>; status_argv?: string[] }) {
65  strings = { ...defaults, ...value?.strings }
66  statusArgv = value?.status_argv
67}
68export function t(key: string, values: Record<string, string | number> = {}): string {
69  return (strings[`claude_pane_${key}`] ?? key).replace(/\{(\w+)\}/g, (match, name) => String(values[name] ?? match))
70}
71
hooks/parse.ts 59 lines
1import { t } from './strings'
2import type { Agent, BgTask } from '../types'
3
4export const STALE_MS = 3600000
5
6export function liveBgTasks(tasks: BgTask[], now: number): BgTask[] {
7  return tasks.filter(task => now - task.start < STALE_MS)
8}
9
10export function backgroundTasks(tasks: readonly { id: string; type: string; description: string; command?: string }[], previous: BgTask[], now: number): BgTask[] {
11  return liveBgTasks(tasks.filter(task => task.type !== 'subagent' && !(task.type === 'shell' && matchAgent(task.command ?? '')))
12    .map(task => ({ id: task.id, type: task.type, label: task.description || task.command || '', start: previous.find(old => old.id === task.id)?.start ?? now })), now)
13}
14
15// A dispatch starts a command segment: line start, or after && || ; | — optionally behind VAR=value prefixes.
16const DISPATCH = /(?:^|&&|\|\||[;|\n])\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(codex exec|agy -p|grok --prompt-file|muse exec)(?=\s|$)/
17
18const AGENT_OF: Record<string, Agent> = {
19  'codex exec': 'codex',
20  'agy -p': 'agy',
21  'grok --prompt-file': 'grok',
22  'muse exec': 'muse',
23}
24
25export function matchAgent(command: string): Agent | null {
26  // drop heredoc bodies only: a brief written with cat <<'EOF' may come before the dispatch line
27  const head = command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2[ \t]*(?=\n|$)/g, '')
28  if (/\s--help(?=\s|$)/.test(head)) return null
29  const key = DISPATCH.exec(head)?.[1]
30  return key ? (AGENT_OF[key] ?? null) : null
31}
32
33// A trailing lone & backgrounds in the shell: the tool call returns at once and no notification follows.
34export function isShellBackgrounded(command: string): boolean {
35  return /(?:^|[^&])&\s*$/.test(command.trimEnd())
36}
37
38// The task-notification text format is not a typed field; parse.test.ts pins it.
39export function parseNotifications(text: string): { toolUseId?: string; taskId?: string; status: string }[] {
40  return text
41    .split('<task-notification>')
42    .slice(1)
43    .flatMap(block => {
44      const id = /<tool-use-id>([^<]+)<\/tool-use-id>/.exec(block)?.[1]
45      const taskId = /<task-id>([^<]+)<\/task-id>/.exec(block)?.[1]
46      const status = /<status>([^<]+)<\/status>/.exec(block)?.[1]
47      return (id || taskId) && status ? [{ ...(id ? { toolUseId: id } : {}), ...(taskId ? { taskId } : {}), status }] : []
48    })
49}
50
51export function formatElapsed(ms: number): string {
52  const s = Math.max(0, Math.floor(ms / 1000))
53  if (s < 60) return t('seconds', { count: s })
54  const m = Math.floor(s / 60)
55  if (m < 60) return `${t('minutes', { count: m })}${t('seconds', { count: String(s % 60).padStart(2, '0') })}`
56  return `${t('hours', { count: Math.floor(m / 60) })}${t('minutes', { count: String(m % 60).padStart(2, '0') })}`
57}
58
59
hooks/quota.ts 107 lines
1import { t } from './strings'
2import type { Quotas, Window } from '../types'
3export function byTightest(data: Quotas, order: readonly string[], now = Date.now(), elapsedSeconds = 0): string[] {
4  return order.map((key, index) => {
5    const agent = data.agents[key]
6    const windows = [agent?.five_hour, agent?.seven_day, agent?.period, ...(agent?.groups ?? []).flatMap(group => [group.five_hour, group.seven_day])]
7    const used = Math.max(-Infinity, ...windows.map(window => window ? effectivePercent(window, now, elapsedSeconds) : undefined).filter((value): value is number => typeof value === 'number' && Number.isFinite(value)))
8    return { key, index, used, available: agent?.available === true }
9  }).sort((a,b) => Number(b.available) - Number(a.available) || b.used - a.used || a.index - b.index).map(row => row.key)
10}
11// agy and Grok are optional, so like the menu bar they stay hidden unless they are set up and failing.
12export function showsUnavailable(key: string, reason?: string): boolean {
13  return key === 'claude-code' || key === 'codex' || reason === 'error'
14}
15export function parseQuota(text: string): Quotas {
16  // A window without a numeric used_percent has no data; drop it so it is hidden instead of drawn as 0%.
17  const value = JSON.parse(text, (_key, v) =>
18    v && typeof v === 'object' && 'used_percent' in v && !Number.isFinite(v.used_percent) ? undefined : v)
19  if (!value || typeof value.agents !== 'object' || value.agents === null) throw new Error(t('quota_invalid'))
20  const agents = Object.fromEntries(Object.entries(value.agents).filter(([, agent]) =>
21    agent !== null && typeof agent === 'object').map(([key, agent]) => {
22    const row = agent as Quotas['agents'][string]
23    return [key, row.available === true ? row : { available: false, reason: ['not_signed_in','no_data','error'].includes(row.reason ?? '') ? row.reason : 'no_data' }]
24  })) as Quotas['agents']
25  // agy's Claude / GPT pool barely moves and the menu bar panel already shows it; in the pane it is noise.
26  const agy = agents.antigravity
27  if (agy?.groups) agents.antigravity = { ...agy, groups: agy.groups.filter(group => !['CLAUDE AND GPT MODELS', 'Claude / GPT'].includes(group.name)) }
28  return { agents }
29}
30export function hideAgents(data: Quotas, preferences: string): Quotas {
31  let value: unknown
32  try { value = JSON.parse(preferences) } catch { return { ...data, agents: { ...data.agents } } }
33  if (!value || typeof value !== 'object' || Array.isArray(value)) return { ...data, agents: { ...data.agents } }
34  const keys: Record<string,string> = { hide_claude_section:'claude-code', hide_codex_section:'codex', hide_agy_section:'antigravity', hide_grok_section:'grok' }
35  const hidden = new Set(Object.entries(keys).filter(([key]) => (value as Record<string,unknown>)[key] === true).map(([,agent]) => agent))
36  return { ...data, agents: Object.fromEntries(Object.entries(data.agents).filter(([agent]) => !hidden.has(agent))) }
37}
38export function countdown(seconds: number): string {
39  const s = Math.max(0, Number.isFinite(seconds) ? seconds : 0)
40  if (s < 3600) return t('minutes', { count: Math.floor(s / 60) })
41  if (s < 86400) return t('hours', { count: Math.floor(s / 3600) })
42  return t('days', { count: Math.floor(s / 86400) })
43}
44export function effectivePercent(window: Window, now: number, elapsedSeconds = 0): number {
45  const reset = window.resets_at ?? (now / 1000 - elapsedSeconds + (window.resets_in_seconds ?? Infinity))
46  return reset <= now / 1000 ? 0 : window.used_percent
47}
48export function quotaLine(label: string, window: Window, now: number, ageSeconds = 0, bodyColumns?: number) {
49  const used = effectivePercent(window, now, ageSeconds)
50  const percent = Math.max(0, Math.min(100, Number.isFinite(used) ? used : 0))
51  const barWidth = bodyColumns === undefined ? 16 : Math.max(10, Math.min(20, Math.floor(bodyColumns - 24)))
52  const filled = Math.round(percent * barWidth / 100)
53  const seconds = window.resets_at === undefined ? (window.resets_in_seconds ?? 0) - ageSeconds : window.resets_at - now / 1000
54  // CJK characters take two columns; pad every label to the width of 5小時
55  const width = [...label].reduce((sum, ch) => sum + (ch.charCodeAt(0) > 0xff ? 2 : 1), 0)
56  const parts = {
57    label: `${label}${' '.repeat(Math.max(0, 5 - width))} `,
58    filled: '━'.repeat(filled),
59    empty: '─'.repeat(barWidth - filled),
60    percent: `${Math.round(percent)}%`.padStart(4),
61    countdown: ` ↻${seconds <= 0 && (window.resets_at !== undefined || window.resets_in_seconds !== undefined) ? t('reset_done') : countdown(seconds)}`,
62  }
63  return { ...parts, text: `${parts.label}${parts.filled}${parts.empty} ${parts.percent}${parts.countdown}`,
64    color: percent >= 80 ? 'red' : percent >= 50 ? 'yellow' : 'green' }
65}
66
67export function fmtDuration(seconds: number): string {
68  if (seconds >= 86400) return `${Math.floor(seconds / 86400)}d${Math.floor(seconds % 86400 / 3600)}h`
69  if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h${Math.floor(seconds % 3600 / 60)}m`
70  if (seconds >= 60) return `${Math.floor(seconds / 60)}min`
71  return `${Math.trunc(seconds)}s`
72}
73export function progressParts(value: number, bodyColumns = 44) {
74  const percent = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0))
75  const width = bodyColumns < 44 ? 8 : 10
76  // Python's round uses the even neighbour for exact halves.
77  const round = (value: number) => value % 1 === 0.5 ? 2 * Math.round(value / 2) : Math.round(value)
78  const filled = round(percent * width / 100)
79  return { filled: '■'.repeat(filled), empty: '□'.repeat(width - filled), percent: `${round(percent)}%`,
80    color: percent < 50 ? '#00d787' : percent < 80 ? '#ffaf00' : '#d70000' }
81}
82export function resetTime(window: Window, now: number, elapsedSeconds = 0): string {
83  const reset = window.resets_at ?? (now / 1000 - elapsedSeconds + (window.resets_in_seconds ?? Infinity))
84  if (reset <= now / 1000) return t('reset_done')
85  const remain = Math.trunc(reset) - Math.trunc(now / 1000)
86  if (!Number.isFinite(remain)) return ''
87  const word = t('remaining'), duration = fmtDuration(remain)
88  return word === '剩' ? `${word}${duration}` : `${duration} ${word}`
89}
90export function dockQuotaLine(label: string, window: Window, now: number, elapsedSeconds = 0, bodyColumns = 44) {
91  const parts = progressParts(effectivePercent(window, now, elapsedSeconds), bodyColumns)
92  const width = [...label].reduce((sum, ch) => sum + (ch.charCodeAt(0) > 0xff ? 2 : 1), 0)
93  const time = resetTime(window, now, elapsedSeconds)
94  return { ...parts, label: `${label}${' '.repeat(Math.max(0, 5 - width))} `,
95    percent: parts.percent.padStart(4), countdown: time ? `  ${time}` : '' }
96}
97export function refreshedAgo(now: number, last: number | null): string {
98  if (last === null) return '↻ —'
99  const seconds = Math.max(0, Math.floor((now - last) / 1000))
100  return `↻ ${seconds < 60 ? t('seconds_ago', { count: seconds }) : t(seconds < 3600 ? 'minutes_ago' : 'hours_ago', { count: Math.floor(seconds / (seconds < 3600 ? 60 : 3600)) })}`
101}
102export function staleAge(ageSeconds: number, now: number, last: number | null): string {
103  const age = Math.max(0, ageSeconds + (last === null ? 0 : (now - last) / 1000))
104  if (age <= 600) return ''
105  return ` · ${t(age >= 3600 ? 'hours_ago' : 'minutes_ago', { count: Math.floor(age / (age >= 3600 ? 3600 : 60)) })}`
106}
107
hooks/compact.ts 52 lines
1import { t } from './strings'
2import type { Quotas, Session, Window } from '../types'
3import { quotaLine, refreshedAgo } from './quota'
4import { isBusy } from './sessions'
5
6export type CompactPart = { text: string; color?: string; bold?: boolean }
7export function compactLines(data: Quotas, sessions: Session[], running: number, now: number, last: number | null): [CompactPart[], CompactPart[], CompactPart[]] {
8  const first: CompactPart[] = [], second: CompactPart[] = []
9  function percent(row: CompactPart[], window: Window | undefined) {
10    if (!window) return
11    const line = quotaLine('', window, now, last === null ? 0 : (now-last)/1000)
12    row.push({ text: line.percent.trim(), color: line.color })
13  }
14  for (const [key, name, color, row] of [
15    ['claude-code', 'Claude', '#d97757', first],
16    ['codex', 'Codex', '#10a37f', first],
17    ['antigravity', 'agy', '#4285f4', second],
18    ['grok', 'Grok', 'white', second],
19  ] as const) {
20    const agent = data.agents[key]
21    if (!agent?.available) continue
22    if (row.length) row.push({ text: '   ' })
23    row.push({ text: `◆ ${name}`, color, bold: key === 'grok' })
24    if (key === 'antigravity') {
25      let hasGroup = false
26      for (const group of agent.groups ?? [{ name: '', five_hour: agent.five_hour, seven_day: agent.seven_day }]) {
27        if (!group.five_hour && !group.seven_day) continue
28        const label = group.name === 'GEMINI MODELS' ? 'Gemini' : ['CLAUDE AND GPT MODELS', 'Claude / GPT'].includes(group.name) ? 'Claude/GPT' : group.name
29        row.push({ text: `${hasGroup ? '  ' : ' '}${label}${label ? ' ' : ''}` })
30        percent(row, group.five_hour)
31        if (group.five_hour && group.seven_day) row.push({ text: '/' })
32        percent(row, group.seven_day)
33        hasGroup = true
34      }
35    } else if (key === 'grok') {
36      if (agent.period) row.push({ text: ' ' })
37      percent(row, agent.period)
38    } else {
39      for (const [label, window] of [[t('five_hour'), agent.five_hour], [t('week'), agent.seven_day]] as const) {
40        if (!window) continue
41        row.push({ text: ` ${label} ` })
42        percent(row, window)
43      }
44    }
45  }
46  return [first, second, [
47    { text: t('compact_summary', { busy: sessions.filter(isBusy).length, total: sessions.length }) },
48    { text: String(running), color: running > 0 ? 'yellow' : undefined },
49    { text: ` · ${refreshedAgo(now, last)}` },
50  ]]
51}
52
hooks/sessions.ts 86 lines
1import { t } from './strings'
2import type { Session } from '../types'
3export function parseSession(text: string): Pick<Session, 'title' | 'source'> & { preview: string } {
4  let title = '', prompt = '', source = '', preview = ''
5  for (const line of text.split('\n')) {
6    let row
7    try { row = JSON.parse(line) } catch { continue }
8    if (!row || typeof row !== 'object') continue
9    if (row.type === 'ai-title' && typeof row.aiTitle === 'string') title = row.aiTitle.trim()
10    if (row.type === 'last-prompt' && typeof row.lastPrompt === 'string') prompt = row.lastPrompt.trim()
11    if (typeof row.entrypoint === 'string') source = row.entrypoint
12    if (row.type === 'assistant' && row.isSidechain !== true) {
13      const content = row.message?.content
14      const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join(' ') : ''
15      const clean = text.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/\*\*|__|`/g, '').replace(/^\s*(?:#+\s*|[-*>]\s+|\d+[.)]\s+)/gm, '').replace(/\s+/g, ' ').trim().slice(0,200)
16      if (clean) preview = clean
17    }
18  }
19  return { title: title || prompt || t('untitled'), source: source === 'cli' ? t('terminal') : source === 'claude-desktop' ? t('desktop') : source, preview }
20}
21export type LiveSession = { pid: number; sessionId: string; name: string; entrypoint: string; status: string; statusUpdatedAt: number; cwd: string; waitingFor?: string }
22export function parseLiveSession(text: string): LiveSession | null {
23  let row
24  try { row = JSON.parse(text) } catch { return null }
25  if (!row || typeof row !== 'object' || !Number.isSafeInteger(row.pid) || row.pid <= 0 || typeof row.sessionId !== 'string' || !row.sessionId) return null
26  const timestamp = row.statusUpdatedAt ?? row.updatedAt
27  if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) return null
28  return { pid: row.pid, sessionId: row.sessionId, name: typeof row.name === 'string' ? row.name : '', entrypoint: typeof row.entrypoint === 'string' ? row.entrypoint : '', status: typeof row.status === 'string' ? row.status : '', statusUpdatedAt: timestamp, cwd: typeof row.cwd === 'string' ? row.cwd : '', ...(typeof row.waitingFor === 'string' && row.waitingFor.trim() ? { waitingFor: row.waitingFor } : {}) }
29}
30export function liveSessions(rows: LiveSession[], psOutput: string): LiveSession[] {
31  const pids = new Set(psOutput.trim().split(/\s+/).map(Number))
32  return rows.filter(row => pids.has(row.pid))
33}
34export function tasklistPids(output: string): string {
35  return output.split(/\r?\n/).map(line => line.match(/^"(?:[^"]|"")*","([^"]*)"/)?.[1]).filter((pid): pid is string => !!pid).join(' ')
36}
37export function toSession(row: LiveSession, transcript = ''): Session {
38  const { title, preview } = parseSession(transcript)
39  let cwd = row.cwd
40  if (!cwd) for (const line of transcript.split('\n')) {
41    try { const entry = JSON.parse(line); if (typeof entry?.cwd === 'string') cwd = entry.cwd } catch { /* skip malformed transcript rows */ }
42  }
43  return { id: row.sessionId, pid: row.pid, title: title === t('untitled') ? row.name : title, source: projectSource(cwd, row.entrypoint), mtimeMs: row.statusUpdatedAt, status: row.status, ...(preview ? { preview } : {}), ...(row.waitingFor ? { waitingFor: row.waitingFor } : {}) }
44}
45export function isBusy(session: Session): boolean { return session.status === 'busy' || (session.jobs ?? 0) > 0 }
46export function sessionNotifications(previous: Session[], next: Session[], currentId: string): { session: Session; kind: 'done' | 'waiting' }[] {
47  const busy = new Set(previous.filter(isBusy).map(session => session.id))
48  const waiting = new Set(previous.filter(session => session.status === 'waiting').map(session => session.id))
49  return next.filter(session => session.id !== currentId && ((session.status === 'idle' && !isBusy(session)) || (session.status === 'waiting' && !waiting.has(session.id))) && busy.has(session.id)).map(session => ({ session, kind: session.status === 'waiting' ? 'waiting' : 'done' }))
50}
51export function waitingText(reason?: string): string {
52  if (!reason?.trim()) return t('waiting')
53  const keys: Record<string,string> = { 'input needed':'input', 'dialog open':'dialog', 'sandbox request':'sandbox', 'worker request':'worker', 'goal proposal':'goal' }
54  const key = Object.hasOwn(keys,reason) ? keys[reason] : undefined
55  return t('waiting_for', { reason: key ? t(`waiting_${key}`) : reason })
56}
57export function settleNotifications(pending: { id: string; kind: 'done' | 'waiting' }[], previous: Session[], next: Session[], currentId: string): { toast: { session: Session; kind: 'done' | 'waiting' }[]; pending: { id: string; kind: 'done' | 'waiting' }[] } {
58  const toast = pending.flatMap(({ id, kind }) => {
59    const session = next.find(session => session.id === id && id !== currentId && session.status === (kind === 'done' ? 'idle' : 'waiting') && (kind !== 'done' || !isBusy(session)))
60    return session ? [{ session, kind }] : []
61  })
62  return { toast, pending: sessionNotifications(previous,next,currentId).map(({session,kind}) => ({id:session.id,kind})) }
63}
64const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
65// Spinner while working, ? while waiting on you, ✓ for ten minutes after finishing, then a gray dot.
66export function sessionMark(session: Session, waiting: boolean, now: number): { text: string; color?: string } {
67  if (waiting) return { text: '?', color: 'yellow' }
68  if (isBusy(session)) return { text: SPINNER[Math.floor(now / 1000) % SPINNER.length]!, color: 'green' }
69  return now - session.mtimeMs < 600000 ? { text: '✓', color: 'green' } : { text: '●' }
70}
71export function sessionStatus(session: Session, now: number): string {
72  return isBusy(session) ? t('busy') : t('idle', { ago: ago(session.mtimeMs, now) })
73}
74export function newest(sessions: Session[]): Session[] {
75  return [...sessions].sort((a,b) => Number(isBusy(b)) - Number(isBusy(a)) || b.mtimeMs - a.mtimeMs)
76}
77
78export function ago(time: number, now: number): string {
79  const minutes = Math.max(0, Math.floor((now - time) / 60000))
80  return minutes === 0 ? t('just_now') : t('minutes_ago', { count: minutes })
81}
82export function projectSource(cwd: string, entrypoint: string): string {
83  const project = cwd.replace(/[\\/]+$/, '').split(/[\\/]+/).pop() || (cwd.startsWith('/') ? '/' : t('no_project'))
84  return `${project}${entrypoint && entrypoint !== 'cli' ? ` · ${t('desktop')}` : ''}`
85}
86
hooks/display.ts 41 lines
1import { t } from './strings'
2import type { AgentInfo } from 'claude-code'
3import type { Run, BgTask } from '../types'
4import { ago } from './sessions'
5export type LiveContext = { sessionId: string; percent?: number; tokens?: number; waiting?: boolean; jobs?: number; updatedAt: number }
6export function contextPercent(percent: number | undefined): number | undefined {
7  return typeof percent === 'number' && Number.isFinite(percent) ? Math.round(Math.max(0, Math.min(100, percent))) : undefined
8}
9export function contextColor(percent: number, tokens?: number): 'yellow' | 'red' | undefined {
10  return percent >= 80 || (tokens !== undefined && tokens >= 400000) ? 'red' : percent >= 50 || (tokens !== undefined && tokens >= 200000) ? 'yellow' : undefined
11}
12export function parseContext(text: string): LiveContext | null {
13  try {
14    const row = JSON.parse(text)
15    if (!row || typeof row.sessionId !== 'string' || !row.sessionId || (row.percent !== undefined && (!Number.isInteger(row.percent) || row.percent < 0 || row.percent > 100)) || (row.waiting !== undefined && typeof row.waiting !== 'boolean') || typeof row.updatedAt !== 'number' || !Number.isFinite(row.updatedAt)) return null
16    return { sessionId: row.sessionId, ...(row.percent === undefined ? {} : { percent: row.percent }), ...(Number.isInteger(row.tokens) && row.tokens >= 0 ? { tokens: row.tokens } : {}), ...(row.waiting === undefined ? {} : { waiting: row.waiting }), ...(Number.isInteger(row.jobs) && row.jobs >= 0 ? { jobs: row.jobs } : {}), updatedAt: row.updatedAt }
17  } catch { return null }
18}
19export function staleContext(sessionId: string, updatedAt: number, openIds: Set<string>, now: number): boolean {
20  return !openIds.has(sessionId) && now - updatedAt > 86400000
21}
22export function completedAgo(run: Pick<Run, 'end' | 'status'>, now: number): string {
23  return run.end === null ? '' : ` · ${t('completed_ago', { ago: ago(run.end, now), status: ['killed', 'stopped', 'cancelled'].includes(run.status) ? t('stopped') : run.status === 'failed' ? t('failed') : t('completed') })}`
24}
25export function isWaiting(context: Pick<LiveContext, 'waiting' | 'updatedAt'>, now: number): boolean {
26  return context.waiting === true && now - context.updatedAt <= 600000
27}
28export function visibleAgents(agents: AgentInfo[]): AgentInfo[] {
29  return agents.filter(agent => ['pending','running','waiting','idle'].includes(agent.status))
30}
31export function backgroundCount(runs: Pick<Run, 'end'>[], agents: AgentInfo[], tasks: BgTask[] = []): number {
32  return tasks.length + runs.filter(run => run.end === null).length + visibleAgents(agents).length
33}
34// Running jobs always show; finished ones only fill the rows left out of four, unless expanded.
35export function visibleRuns(runs: Run[], expanded: boolean, tasks: BgTask[] = []): { rows: (Omit<Run, 'agent'> & { agent: string })[]; hiddenDone: number } {
36  const jobs = [...runs, ...tasks.map(task => ({ id: task.id, agent: task.type, label: task.label, start: task.start, end: null, status: 'running' }))]
37  const done = jobs.filter(r => r.end !== null)
38  const kept = new Set(expanded ? done : done.slice(Math.max(0, done.length - Math.max(0, 4 - (jobs.length - done.length)))))
39  return { rows: jobs.filter(r => r.end === null || kept.has(r)), hiddenDone: done.length - kept.size }
40}
41
types/index.d.ts 16 lines
1export type Agent = 'codex' | 'agy' | 'grok' | 'muse'
2export type Run = { id: string; agent: Agent; label: string; start: number; end: number | null; status: string }
3export type BgTask = { id: string; type: string; label: string; start: number }
4export type Window = { used_percent: number; resets_in_seconds?: number; resets_at?: number }
5export type QuotaAgent = { available: boolean; reason?: 'not_signed_in' | 'no_data' | 'error'; model?: string; tier?: string; age_seconds?: number; five_hour?: Window; seven_day?: Window; period?: Window; groups?: { name: string; five_hour?: Window; seven_day?: Window }[] }
6export type Quotas = { agents: Record<string, QuotaAgent> }
7export type Session = { id: string; title: string; source: string; mtimeMs: number; pid: number; status: string; preview?: string; waitingFor?: string; contextPercent?: number; contextTokens?: number; waiting?: boolean; waitingUpdatedAt?: number; jobs?: number }
8declare module 'claude-code' {
9  interface PluginState {
10    'usage-dash': {
11      quotas: Quotas; updated: number | null; quotaError: string; sessions: Session[]; sessionError: string; pendingToasts: { id: string; kind: 'done' | 'waiting' }[];
12      runs: Run[]; bgTasks: BgTask[]; agents: AgentInfo[]; collapsed: { quota: boolean; sessions: boolean; runs: boolean }; more: boolean; moreRuns: boolean;
13    }
14  }
15}
16