QuotaSaver — Wave Your Prompts for Claude Code
See which of your Claude Code conversations are actually working, click one to jump straight to its tab — in any VS Code window, even a hidden one — launch a whole batch of conversations in ordered waves from one paste, and hear the difference between "done" and "Claude needs you". Most Claude Code usage trackers on the Marketplace stop at a status-bar percentage. This one is a full panel, docked in the Secondary Side Bar (right side):
Installing and opening the panelThe panel docks in VS Code's Secondary Side Bar — the right-hand sidebar. Since VS Code 1.104 that sidebar opens by itself when you open a folder, so the panel is usually right there after installing. If you see nothing at all, it is almost always because that sidebar is closed — it stays hidden in an empty window, and it stays closed if you closed it once:
It stays docked from then on: you will not have to do any of this again. An empty conversation list in a workspace where no Claude Code conversation is open is normal, not a failure — rows appear as you start conversations. If the sidebar opens but QuotaSaver is nowhere in it, the extension has not activated yet: run Claude Code Quota: Open Usage Page once from the Command Palette. The Secondary Side Bar itself needs VS Code 1.106 or newer, which is the extension's minimum anyway. WhyVS Code's own Claude Code extension doesn't show which of your open conversations are actually working — only a blue dot for a pending permission, orange for a finished hidden tab (Anthropic feature request #34309). And the CLI's quota is only visible on request. This panel keeps both visible at all times, reactively. Launching conversations — one, or a whole batch
The New conversation form at the bottom of the conversation list opens real Claude Code conversations — the official extension's own tabs — but parameterized:
A launched batch lives on as a group in the panel:
Nested batchesIt's common to paste a
Batch-created conversations honour the requested model/effort at the CLI level; the official Claude menu inside the tab can briefly display its own persisted default instead, until the first turn resyncs it — the badge on the conversation row (read from the transcript) is the real state. See Known limitations for this and for how the master lookup can come up empty. How it worksThe extension has a single fetch path. OAuth. The extension reads your local Claude Code OAuth token from
Results are cached at Event-driven top-up. During a fast burn, waiting for the next 5-minute tick can leave the panel visibly behind the real usage. Whenever a conversation transitions to You must already be signed into Claude Code. The extension performs no login flow of its own and never asks you for credentials. Model and context occupation, per conversationEach conversation row shows the model actually served for that session and its context-window occupation (
The denominator (200k vs 1M) is auto-detected by
Estimated cost per conversation
When a turn gets expensive, Claude offers to hand overPast a certain cost per turn, the cheapest thing you can do is start a fresh conversation: a long one re-pays for its entire context on every reply. So when the last turn crossed
Conversation state engine
Reactive by design: States
There is no grey dot: a conversation that is simply finished shows a dim ✓, not a "this was pointless" pellet. An interruption gets its own shape rather than a shade of the ✓, because it means the opposite: a dim ✓ says "nothing to do here", while a stopped conversation is unfinished work you meant to come back to — the row you go looking for twenty minutes later. A conversation is only listed while its tab is open somewhere — closing the tab removes the row, whether or not the CLI process behind it is still running (2.60.0). A conversation pinned "to review" is the one exception. See When a conversation disappears. Three corrections are applied on read, because the hooks alone can't express them. They all come down to the same rule: the hooks say what happened, the transcript says what is happening.
Every wait looks the same, and none of them waits on
|
| Situation | Behaviour | Why |
|---|---|---|
busy/waiting with no tab |
Kept — until the store publishes that session | A legitimate CLI/terminal session has no tab — but the hooks' record outlives the process it describes, so it can't outrank a real proof |
| Live CLI process for that session | Kept — until the store publishes that session | Process identity is stable where captions are not — but a process outlives its tab (2.60.0) |
Title is a fallback (no ai-title, no tab title) |
Kept — until the store publishes that session | Only a real tab title can be matched; a fallback can't, so "no match" proves nothing — unless the store supplies the matchable title itself |
| Session published by the store, no tab under that name | Hidden (2.60.0) | The proof exists and it is negative — process alive or not, hooks or no hooks, fallback title or not |
| Tab store silent (0 entries where the file isn't empty) | Kept | A source returning nothing is a failure, not a reading: nobody is published, all three exemptions above resume their old role, and nothing disappears that wouldn't have before |
| Pinned "to review" with no tab | Kept, struck through | A pin is an instruction from you, not a missing proof — the one reason a row outlives its tab |
| Tab dragged between groups/windows | Kept | A close is only confirmed 150 ms later, against the union — if the label came back, it was never closed |
Stale <pid>.json from a reused pid |
Kept | Phantom tabs keep a conversation visible — the pre-existing behaviour, never a loss of information |
| Tab closed under your eyes | Hidden | An explicit close wins over everything, including a live process |
Tab titles vs transcript titles
The title in the transcript (ai-title) is not the tab caption. The official extension keeps its own session titles in the workspace's state.vscdb (key agentSessions.model.cache, entries {resource: "claude-code:/<sessionId>", label}) and re-labels tabs from there without writing a new ai-title. Once they diverge, matching on ai-title alone finds nothing: the conversation looks tab-less and gets hidden, and clicking its row focuses nothing.
That table is therefore read (read-only, reopened and closed on each refresh, at most once per 30 s tick) and used for matching, click-to-focus, tab-order sorting and for the row caption itself — a conversation shows the name you see on its tab. Both this table and the live-session registry are undocumented internals: if either is missing or unreadable, the panel silently falls back to its previous behaviour, and neither can hide a conversation that would otherwise show.
When a sessions-state.json entry has no transcript on disk
An aborted session can enter sessions-state.json (via UserPromptSubmit) carrying a transcript path whose file never gets created — the process died before its first write. Without a file, there's no title, no model, no tab to match against, and the presence filter above can't confirm it's gone (no ai-title to trust) — a ghost "Conversation" row that never leaves.
Such an entry is simply never rendered. A brand-new, legitimate conversation can precede its own transcript's first write by a couple of seconds — that's not treated as debris, the row just doesn't show up until the file does. An entry still missing its transcript after 5 minutes is dropped from sessions-state.json outright — SessionEnd isn't reliable enough to count on to clean it up (see above).
Tab detection drift (canary)
Every tab↔conversation match — click-to-focus, the presence filter above, read receipts — depends on viewType.includes('claudeVSCodePanel') (labels.js). If the official Claude Code extension ever renames that viewType, none of these paths raise an error: they just silently stop matching any tab, and the panel quietly degrades.
There's no way to detect the rename itself, but the symptom is detectable: a conversation busy or waiting in the workspace, and zero Claude tabs seen anywhere, for more than ~2 minutes straight. That's not proof on its own — closing the tab and working from the CLI produces the exact same reading, hence the 2-minute delay before it's treated as a signal rather than normal use. When it fires: a warning is logged to the extension host console, and a small ⚠ Claude tabs not detected — viewType changed? line appears under the conversation list — no popup. It clears the moment a Claude tab is seen again. tabs.known: false (tracker dead, or the API missing entirely) is never read as drift — no data means no conclusion, same rule as the presence filter's own doubt-favours-showing above.
Frozen tab mirror
VS Code's extension host only ever sees tabs through a mirror synced from the renderer over RPC. On rare occasions that mirror can freeze for an entire window — no error, no event — while the real tab bar keeps changing normally. When that happens, clicking the right row in the panel still moves the highlight (the click itself is trusted, ahead of a mirror that no longer is), but the automatic self-repair on tab switches stops working until the window is reloaded. A VS Code stopped reporting tab changes — highlight may lag. Reloading the window fixes it. line appears under the conversation list when this is detected, and clears on its own once the mirror responds again.
When the highlight is corrected
The tab mirror can also adopt a wrong active tab and then never emit anything to correct it — measured at 14 minutes of wrong highlight, ended only by a click. So the panel keeps a judge of last resort: the editor layout VS Code's renderer persists to workspace storage — the process that actually paints your screen, recorded by exact session identity rather than by tab label. Whenever that record is more recent than the tab tracker's last change of mind and disagrees with it, the record wins and the highlight moves back where it belongs.
A correction is never silent: a dismissible ⚠ … highlight was out of sync and has been corrected banner above the list gives the time, the conversation the highlight moved to, and what the panel was wrongly showing. It only fires on a disagreement that has lasted a few seconds — filling a blank highlight is never reported (nothing wrong was on screen), and a transient the tracker resolves on its own stays quiet.
Frozen panel feed
The same class of freeze can hit the opposite direction: the channel that carries state pushes from the extension host to the panel's webview. The engine keeps computing correct states, but the panel keeps painting the last one it received — and since a CSS spinner animates locally, a finished conversation can keep "working" on screen indefinitely, which is the worst possible lie. The panel therefore watches its own feed: while visible, 60 s without receiving anything makes it re-request the state (that direction survives these freezes in practice — clicks kept working during the incident that motivated this); after 3 minutes with no answer it shows The panel stopped receiving updates — statuses shown may be stale. Reloading the window fixes it. and pauses every animation, so nothing on screen claims activity the panel cannot prove. The first state that arrives clears all of it, and returning to the panel after it was hidden requests a fresh state immediately.
API
const { createStateEngine } = require('./state.js');
const engine = createStateEngine({
workspacePath,
tabs: () => ({ known: true, labels: [...] }), // union of every window's tabs; known:false hides nothing
onChange: (snapshot) => { /* push to webview */ },
});
engine.getSnapshot(); // { conversations: [...], activeSessionId, generatedAt }
engine.markClosed([ids]); // tab(s) closed → drop now, without waiting for the state-file purge
engine.dispose();
Each conversation: { sessionId, title, state, acked, since, busySince, model, modelId, ctx: {tokens, denom, pct}, message, isActive, transcript, mtime }.
Titles come from the transcript's ai-title entry — the very title Claude Code shows on the tab — falling back to the first user message carrying actual human text, then the last prompt. A slash-command isn't stored as /model opus but as its internal markup (<command-name>/model</command-name> <command-args>…), and its output as <local-command-stdout>…; those entries are stripped whole, so the fallback lands on the real prompt. The rule matches any leading <tag>…</tag> rather than a list of known names — a list would just reproduce the bug on the CLI's next invention. Chevrons inside a human sentence are left alone ("why does this <div> overflow?").
ai-title is found regardless of where it lands in the file — a transcript is append-only, so state.js keeps a per-file {scannedBytes, aiTitle} cache and scans only the new bytes on each read (full scan once, on first read). Before this, ai-title was only searched in the first 32 KB and last 64 KB of the file; a real 739 KB transcript had it at byte 33,349 — in neither window — so the panel fell back to the first message as the title, and the presence filter (which only trusts a matchable title — ai-title, or a real tab title — to prove a tab is really gone) could never confirm the conversation had closed.
state.js requires no vscode module (workspace is injected), so it runs under plain Node for testing.
Conversations are sorted by activity and truncated before their transcripts are read: reading is the expensive step (64 KB/file), so a 374-transcript project folder costs 10 ms instead of 209 ms.
sessions-state.json
Written by the hooks and by the extension (ack_ts only), merged per session_id, atomically (tmp + rename) under a lock — several Claude sessions write this file concurrently. Every writer must go through updateSession/removeSession; a direct write would clobber a hook's state. Entries older than 24 h are pruned on write.
{
"version": 1,
"sessions": {
"<session_id>": {
"state": "busy", // busy | waiting | done
"since": 1752580000000, // ms epoch, entered THIS state
"updated_at": 1752580000000, // ms epoch, last write
"cwd": "C:\\...",
"transcript": "C:\\...\\<session_id>.jsonl",
"message": "...", // Notification text, when waiting
"ack_ts": 1752580000000, // ms epoch, tab read after the last `done`.
// Written by the EXTENSION, not by a hook:
// "I read it" is not a CLI event.
// Unread = since > ack_ts.
"busy_since": 1752580000000 // ms epoch, start of the CURRENT run.
// Written on every UserPromptSubmit, and
// NOT overwritten by the Stop that follows
// (unlike `since`). Used only for the
// strict read-receipt check above.
}
}
}
Setup
Hooks are optional. Without them the panel still shows every conversation in the workspace (title, model, ctx:%), just without live busy/waiting/done state — every conversation renders idle (see the states table above), since that state is exactly what "no hook entry for this session" means. The quota bars and everything else work the same either way.
To get live state, deploy the hooks with the QuotaSaver: Install Hooks command (Command Palette) — one click, on Windows, macOS or Linux: it shows exactly what will be written, then does it itself (no PowerShell required; nothing runs until you confirm). The same command also deploys the /handoffs command. A panel installed from the Marketplace has neither yet — a banner at the top of the conversation list says so and offers the same one-click install, and it disappears on its own once both are in place.
A Get started with QuotaSaver walkthrough opens the first time the extension runs and covers this same install button plus the other things worth knowing (pasting a claude-convs block, /handoffs, and the optional batching philosophy below) — reopen it anytime from the Command Palette: Welcome: Open Walkthrough... → QuotaSaver.
The hooks and their shared libs live under hooks/ in this repo, the canonical source. If you'd rather deploy them yourself — or from a script — install.ps1 (Windows/PowerShell) does the exact same thing the command does, still idempotent (backs up settings.json and only edits it if an entry is missing):
.\install.ps1
This configures:
{
// status line: renders Claude Code's own native CLI/terminal status line
// (independent of this panel) AND writes current-model.json — unrelated to
// this extension's UI, kept for compatibility with other tools that may read it.
"statusLine": {
"type": "command",
"command": "node /path/to/.claude/scripts/usage-statusline.js",
"refreshInterval": 60
},
"hooks": {
// marks the session `busy` + writes active-session.json from its transcript
"UserPromptSubmit": [
{ "matcher": "", "hooks": [
{ "type": "command", "command": "node /path/to/.claude/scripts/track-active-session.js" }
] }
],
// one script for every event below, routed on `hook_event_name`:
// Stop -> done | SessionEnd -> drop the session
// PermissionRequest / Elicitation / Notification -> waiting
// PermissionDenied / ElicitationResult -> the wait is over, back to busy
"Stop": [ { "matcher": "", "hooks": [ { "type": "command", "command": "node /path/to/.claude/scripts/hook-session-state.js" } ] } ],
"Notification": [ { "matcher": "", "hooks": [ { "type": "command", "command": "node /path/to/.claude/scripts/hook-session-state.js" } ] } ],
"SessionEnd": [ { "matcher": "", "hooks": [ { "type": "command", "command": "node /path/to/.claude/scripts/hook-session-state.js" } ] } ],
"PermissionRequest": [ { "matcher": "", "hooks": [ { "type": "command", "command": "node /path/to/.claude/scripts/hook-session-state.js" } ] } ],
"PermissionDenied": [ { "matcher": "", "hooks": [ { "type": "command", "command": "node /path/to/.claude/scripts/hook-session-state.js" } ] } ],
"Elicitation": [ { "matcher": "", "hooks": [ { "type": "command", "command": "node /path/to/.claude/scripts/hook-session-state.js" } ] } ],
"ElicitationResult": [ { "matcher": "", "hooks": [ { "type": "command", "command": "node /path/to/.claude/scripts/hook-session-state.js" } ] } ]
}
}
The installer always appends its own matcher: "" group rather than joining an existing one: on Notification/SessionEnd an existing group may carry a restrictive matcher (permission_prompt), and grafting onto it would silently narrow the hook. Existing hooks are never touched.
Deployed files: the three hooks (usage-statusline.js, track-active-session.js, hook-session-state.js) plus the libs they require — sessions-state.js (locked atomic writes), model-id.js (model id → display name, window detection), transcript.js (JSONL tail/head reads), turn-cost.js (last-turn cost, handover notice) and cost.js, which turn-cost.js requires so that price list and turn boundary are defined in exactly one place. state.js requires the same libs from hooks/, so both sides always agree on what a model id means. Alongside the hooks, the same install also copies commands/handoffs.md to ~/.claude/commands/handoffs.md, the /handoffs command.
Edit the hooks in hooks/ (not the deployed copies in ~/.claude/scripts/), then re-run QuotaSaver: Install Hooks (or install.ps1).
Batching philosophy (optional)
/handoffs only formats a split you've already decided on — it says nothing about when to offer one. The QuotaSaver: Add Batching Philosophy to CLAUDE.md command deposits a short, extension-maintained note at ~/.claude/claude-convs-batching.md and adds a single @claude-convs-batching.md line to your personal ~/.claude/CLAUDE.md (a relative import, so nothing machine-specific ever gets written to a file you might version or share). The note teaches Claude the judgment call: offer to split a request into a batch only when the split is real (independent parts, or a chain where a later step needs an earlier one), stay in one conversation otherwise.
This is entirely optional — every other part of the extension works the same without it — and strongly recommended, since it's what makes Claude reach for batches at all instead of defaulting to one conversation on the current model for everything. It's asked as a separate question, right after hooks finish installing (at most once per machine — declining is remembered so it won't ask again on its own, but the command stays available any time from the Command Palette or the walkthrough), and it shows the exact text that will be imported into every future conversation before asking, never just the technical line that pulls it in. Re-running it re-deploys the note itself every time, like a hook: a future version of the extension improves it for everyone without anyone re-pasting anything, while your own CLAUDE.md is only ever touched to add that one line, never overwritten (a timestamped backup is made first if it already contains other content).
Requirements
- Claude Code installed and signed in — the quota bars read its OAuth token and work out of the box.
- VS Code 1.106 or later (for the Secondary Side Bar panel).
- Windows, macOS or Linux. The panel, conversation state, quota bars and batching are plain Node — nothing OS-specific. Two optional extras lean on Windows and degrade cleanly elsewhere, never with a visible error: window-raising (Windows only — no portable equivalent, see Clicking a conversation) and sounds (Windows and macOS, silent on Linux, see Sounds).
- Everything above works with zero configuration — conversations,
ctx:%, and the quota bars. One thing is opt-in and degrades cleanly when skipped, never with an error: live conversation state (busy/waiting/doneinstead ofidle), which needs the hooks — see Setup.
Configuration
| Setting | Default | Description |
|---|---|---|
claudeCodeQuotaBar.refreshIntervalMinutes |
5 |
How often to refresh the usage data (minutes). Only affects the network quota fetch — conversation state is event-driven, never polled. |
claudeCodeQuotaBar.burnRateGreenMax |
0.85 |
Burn-rate pace at or below which a quota bar is green. |
claudeCodeQuotaBar.burnRateYellowMax |
1.0 |
Burn-rate pace at or below which a quota bar is yellow (above it, red). |
claudeCodeQuotaBar.ctxRedMin |
50 |
Percent of a conversation's context window at or above which its ctx:% bar is red. |
claudeCodeQuotaBar.ctxYellowMin |
40 |
Percent of a conversation's context window at or above which its ctx:% bar is yellow (below ctxRedMin). |
claudeCodeQuotaBar.costTurnRedDollars |
2 |
Estimated cost of a conversation's last completed turn, in US dollars, at or above which the amount on its row is red. See Estimated cost per conversation. |
claudeCodeQuotaBar.costTurnYellowDollars |
0.5 |
Last-turn cost at or above which the amount is yellow (below costTurnRedDollars; below this, grey). |
claudeCodeQuotaBar.relayNoticeDollars |
5 |
Last-turn cost at or above which Claude is asked — once per conversation, and only after two complete turns — to offer handing the work over to a fresh conversation. 0 disables the notice. Requires the hooks. See When a turn gets expensive. |
claudeCodeQuotaBar.costRedDollars |
5 |
Deprecated — threshold on a conversation's total, which no longer colours anything since the colour moved to the last turn. |
claudeCodeQuotaBar.costYellowDollars |
2 |
Deprecated — same, for the yellow step. |
claudeCodeQuotaBar.sounds.enabled |
false |
Play a system sound on done/waiting transitions. See Sounds. |
Burn-rate colouring

Each quota bar (5h, 7d) is coloured by pace — how fast you're spending the window relative to how much of it has elapsed:
pace = percent_used / percent_of_window_elapsed
Read pace as a projection of where you land at reset time: pace = 1.4 means "at this rate I'd need 140% of the quota to reach the reset". Hence the colours:
pace ≤ 0.85→ green — the projection lands comfortably under the quota.0.85 < pace ≤ 1.0→ yellow — the projection lands close to the quota, at or just under it.pace > 1.0→ red — the projection exceeds the quota: at this rate the window runs out before it resets.
Both thresholds are configurable. The colour is withheld (neutral bar) when the reset time is missing, already past, or too close to "just started" for the ratio to be a meaningful signal — dividing by a near-zero elapsed fraction would produce noise, not a signal.
The ▲ marker: where you should be
Below each bar, a small ▲ sits at % of the window already elapsed — the same denominator the pace formula above uses. Fill to the left of the arrow is on pace; fill past it means you're burning faster than the clock. Example: 24 hours after the weekly reset, the arrow sits at 1/7 ≈ 14.3% of the bar, regardless of how much you've actually used.
It's masked (no arrow) under the same conditions as the colour: no reset time, reset already past, or the window barely started. It's capped at 100%.
The arrow repositions on its own, without waiting for the next network poll: since its position is pure function of the current clock and the reset time (no data to fetch), the webview re-evaluates it locally every 30 seconds and pauses that timer while the panel isn't visible. Same for the colour — both stay accurate between the 5-minute quota fetches.
Light tick marks cut each bar into equal segments — one per hour on the 5h window (5 of them), one per day on a 7-day one (7) — so the fill can be read against elapsed time without doing the arithmetic. They sit in their own thin rail between the fill and the ▲, never inside either. The count is derived from the window's real duration, never from its label.
What actually filled the window
The percentage says how much of a window is gone; it never said what went into it. So each quota line also carries, between its label and its percentage, the measured dollar value of what was consumed inside that window — its reset time minus its length, no date heuristic.
- The period is account-wide, so the measurement is too: it sums every transcript under
~/.claude/projects/, not just the conversations this panel lists. Candidates are filtered by file mtime, then read incrementally through the same accumulator that prices each conversation row — the same bytes are never parsed twice, and nothing is fetched over the network. - A model-scoped weekly line counts only that family's messages. The scope's
model.idisnullin practice, so the match is made ondisplay_name; when it matches nothing, the line shows no amount at all rather than a misleading$0. - Above
$100the figure rounds to the dollar.≈marks the estimate; the tooltip carries the two caveats the line must not: the measurement is local (Claude Code on this PC — whatever goes through claude.ai or the mobile app counts toward the percentage but not toward this amount), and the figure is the value of that usage at API list prices, which a subscription covers rather than a spend. - The first full pass is deferred until the panel is actually visible: a VS Code window whose panel stays closed reads nothing. Later passes are incremental (a few milliseconds) and run once a minute.
Model-scoped weekly limits
Besides the 5h/7d bars, the panel renders one bar for every entry in the usage API's limits[] array that has group: "weekly" and a scope — e.g. a promotional weekly allowance tied to a specific model (Fable's 50% weekly cap, live 2026-07-15 → 2026-07-19). The label comes straight from scope.model.display_name; there is no hardcoded model name or date anywhere in this code, so the bar appears when the API sends the entry and disappears on its own the day it stops. Same colour, arrow, and auto-refresh treatment as the two main bars.
Fetching across several VS Code windows
~/.claude/usage-cache.json is shared by every VS Code window watching the same machine, not just the same workspace — each window does its own 5-minute poll and its own event-driven fetch (above) on the same sessions-state.json transitions, so N open windows used to mean N× the calls to the usage endpoint for the exact same number. Before any automatic fetch (poll or event-driven), the cache's own timestamp is checked first: if another window refreshed it less than 30 seconds ago, this window reads that cache instead of hitting the network again. An explicit user action — the Refresh Now command or the panel's Refresh link — always forces a real fetch regardless, since that's a deliberate "give me the latest" request. The existing cache-only fallback (the network call failing → serve the last cache) is untouched by this: quotaState() reads the cache file directly, independent of whether the fetch that produced it was skipped or attempted.
Clicking a conversation
The panel lists the conversations of the workspace, and a workspace can be open in several VS Code windows at once — so the tab you click may well live in another window. Clicking a row focuses it anyway, and brings that window to the front. This is focus.js + raise-window.ps1, and it is indirect for a reason: VS Code offers no tab↔session mapping, no "activate this tab" API, and no "raise this window" API (see Known limitations).
What actually happens:
- Match the tab by label. The Claude Code extension truncates a tab's label to 24 characters plus an ellipsis (
Refactor auth middlewar…) while the panel shows the fullai-title, so the label is compared as a prefix of the title when it's truncated, and compared exactly otherwise. Nothing matches → nothing happens, rather than focusing the wrong conversation. - Search every editor group, not just the active one. The tab is activated with
workbench.action.openEditorAtIndex, which only ever acts on the active group — so the group is focused first (workbench.action.focusNthEditorGroup). - Not in this window? The click is relayed through
~/.claude/panel-focus-request.json({title, session_id, ts, origin_pid}, written whole + atomic rename). Every window's instance watches that file and searches its own tabs; the one that owns the tab responds, the others ignore it. Requests older than 3 seconds are ignored as leftovers, and an instance never answers its own request. - Raise the window — Windows only.
raise-window.ps1finds it viaEnumWindows—Get-Processcan't, since every window of one VS Code instance belongs to the same process — matching the window title (<active tab> - <folder> - Visual Studio Code) against the tab label. It then triesSetForegroundWindow, retries withAttachThreadInputif Windows refuses the foreground change, and as a last resort just flashes the window's taskbar button rather than leaving the click silent. The outcome (raised/flashed/not-found) is logged to the extension host console. There's no portable equivalent (no VS Code API raises a window, and no cross-platform Win32-style call exists), so on macOS/Linux this step is skipped — the tab still gets focused inside its own window either way, just without jumping the whole window to the front.
Diagnostics: raise-window.ps1 -ListOnly -TitlePrefix "" lists every VS Code window it can see, without touching the foreground. When a click seems to do nothing, the extension host console says which Claude tab labels it actually saw (Help → Toggle Developer Tools, in the window you clicked from).
Regression bench, plain Node, no VS Code needed (the vscode module is stubbed) — node test/test-focus.js (label matching, group search, relay request) and node test/test-relay.js (two processes = two windows, through to the real PowerShell call, without raising anything).
Sounds
A system sound (Windows' ding.wav for done, SystemSounds.Exclamation for waiting; macOS' Glass.aiff/Sosumi.aiff via afplay) can play when a conversation finishes replying or hands control back to you — useful when the panel isn't on screen. Off by default — no surprise sound on install. Toggle it from the 🔈/🔊 icon at the top of the panel, or claudeCodeQuotaBar.sounds.enabled in settings; toggling one window updates the icon in every other window watching the same workspace.
- Needs the hooks. Sounds fire on a
busy→done/waitingtransition — without the hooks (see Setup), every conversation staysidleforever, so a sound would never play no matter how long you wait. Turning the toggle on without the hooks installed shows a one-time warning (Install hooks/Enable anyway/Turn sounds back off) instead of just staying silently mute. - Played from the extension host, never the webview — a hidden or closed panel's JavaScript is suspended, exactly when the sound is needed. On Windows a detached, hidden PowerShell (
-WindowStyle Hidden -NonInteractive) plays the sound; on macOS,afplay. Either way nothing blocks the extension host on it. Linux has no guaranteed audio player without an extra dependency, so it stays silent there — the toggle still works, it just never plays anything. - Debounced against the same Stop-hook-with-feedback rebound the state engine itself corrects (a
Stopthat isn't really the end of the turn, see Conversation state engine): adonetransition arms a ~2.5 s timer before it plays, cancelled if the conversation goes back tobusyin that window.waitingplays immediately — it's the urgent one — but only on an actual transition; a repeat notification of the same state never replays it. - One "done" sound per turn, not per engine stop. A
Stophook that returns feedback (exit code 2) doesn't correct a false ending — it sends Claude back for another turn, which ends in a second, entirely realStop. Both are genuine endings to the engine, so both used to ring, 10–25 s apart. What the sound announces is control is yours again, so the done claim is keyed on the turn you started (busy_since, set by theUserPromptSubmithook), never on the timestamp of the stop: one turn, one ding, however many times a hook sends Claude back — including hooks this extension knows nothing about. It rings at the first ending of the turn, so when a hook adds work behind it, it comes a few seconds early — preferable to delaying every conversation's ending by 30 s. waiting stays keyed per transition: several permission prompts in one turn are several real calls for you, each worth its own sound. Withoutbusy_since(hooks not installed, or an older version), it falls back to the previous behaviour — never to silence. - Deduplicated across windows. Every VS Code window watches the same
sessions-state.json, so a conversation finishing would otherwise ring in all of them. The first window to observe a transition claims it in~/.claude/sound-claims.json(written under the same lock assessions-state.json— never a direct write); the others see the claim and stay silent. Claims older than 24 h are pruned on write, same rule as the session state file. - Conflict with VS Code's own accessibility sounds.
accessibility.signals.chatResponseReceived/chatUserActionRequiredcan already play a sound for the same events. The first time this toggle turns on with either set tosound: "on", a one-time prompt offers to turn them off; the choice (either way) is remembered in the extension's own state and never asked again.
Regression bench, plain Node — node test/test-sounds.js (debounce, the Stop→busy cancel, per-conversation isolation, one sound per turn across several engine stops, and two real child processes racing for the same claim).
Commands
- Claude Code Quota: Open Usage Page — opens
claude.ai/settings/usage. - Claude Code Quota: Refresh Now — forces an immediate quota refresh.
- QuotaSaver: Install Hooks — deploys the hooks (see Setup) after showing exactly what will be written, for live conversation state.
- QuotaSaver: Add Batching Philosophy to CLAUDE.md — optional, see Batching philosophy; shows the exact text before adding it.
Privacy and data handling
- The only credential this extension reads is the OAuth access token in
~/.claude/.credentials.json, on your machine. It reads no other credential of any kind — since 2.62.0 there is no code path that touches aclaude.aisession cookie, a browser profile, or a password store. - The token is read fresh at each fetch, passed straight into the request header, and sent only to
api.anthropic.com(HTTPS) to query usage. It is never copied to disk, never logged, never transmitted anywhere else. - Exactly one network call exists in the entire extension — the
https.gettoapi.anthropic.comabove. Grep the source forhttps.get,http.get,fetch(orWebSocket: that single call is the only hit. There is no telemetry, no analytics, no third-party host, and the panel webview runs underdefault-src 'none'so it cannot fetch anything at all. - The cache file
~/.claude/usage-cache.jsoncontains only the JSON response from Anthropic's usage endpoint (percentages and reset timestamps). No prompts, no chat content, no personal data beyond what's already in your Claude Code install. - The model cache
~/.claude/current-model.json(written by thestatusLinehook, for Claude Code's own native status line) contains only the model's display name + ID and a timestamp. This extension no longer reads it. - All code is plain JavaScript in
extension.js/panel.js/state.js/focus.js/tabs.js/ack.js/labels.js/sounds.js(plusraise-window.ps1, which only ever reads window titles and calls the foreground/flash APIs) — review it locally in the extension folder under~/.vscode/extensions/AnthonyDame.claude-code-quota-bar-*/. - Read receipts observe only which tab is active and whether the window has focus — never what's in it. What's persisted is a pair of timestamps (
ack_ts,busy_since), on your machine. ~/.claude/panel-focus-request.json(focus relay) holds the clicked conversation's title and session id for a few seconds so the VS Code window owning that tab can answer. It never leaves the machine.~/.claude/panel-tabs/<pid>.jsonholds the Claude tab labels currently open in that VS Code window, so the other windows can tell which conversations are still open. Labels are conversation titles, i.e. content you already see on your own tabs. Written on every tab change, deleted when the window closes, and never leaves the machine.~/.claude/sound-claims.json(sounds, off by default) holds which window already played the sound for a given conversation transition — a session id, a state (done/waiting), and a timestamp. No prompt or chat content. Pruned after 24 h.~/.claude/quota-session-key.json,~/.claude/quota-org-id.jsonand~/.claude/quota-brave-pid.jsonwere written by theclaude.aicookie path removed in 2.62.0 — the first of the three held a live session cookie in clear text. Nothing reads or writes them any more; if they exist on your machine from an earlier version, delete them.~/.claude/quotabar-turns/<session>.json(one small file per conversation, written by theUserPromptSubmithook) holds the byte offset reached in that conversation's transcript, the running cost figures, and whether the handover notice has already been shown. Numbers and an offset — no prompt, no chat content. Files untouched for 7 days are deleted automatically.~/.claude/quotabar-cost-daily.json(written by the same hook, at most once per calendar day) holds a running history of what every conversation on the machine has cost, one entry per day: a total, a message count, and a breakdown by model — nothing else, no prompt or chat content. It's rebuilt from your local transcripts (never uploaded anywhere), going back to whatever history is already on disk the first time it runs, so it isn't limited to "from today on". Purely a data log for now — there's no view for it in the panel yet.~/.claude/sessions-state.jsonis written by the hooks (not by this extension, except forack_ts— see Read receipts): per-conversation state, a working directory, a transcript path, and — forwaiting— the text of theNotificationhook payload (typically "Claude is waiting for your input" or similar, not your prompt). Only present if you've run Setup; without it, every conversation just showsidle.~/.claude/claude-convs-batching.md(see Batching philosophy, opt-in) holds a short, fixed, extension-authored text — no prompt or chat content.~/.claude/CLAUDE.md, your own personal file, only ever receives one line pointing at it (@claude-convs-batching.md), never overwritten; a timestamped backup is made first if the file already has other content.- Every file above lives under
~/.claude/. Nothing this extension writes goes anywhere else on disk, and nothing leaves the machine except the single network call in the first bullet (quota fetch), the "install hooks" command in Setup (touches only~/.claude/scripts/and~/.claude/settings.json), and the batching-philosophy command above (touches only~/.claude/claude-convs-batching.mdand~/.claude/CLAUDE.md) — never silently, always behind an explicit confirmation dialog listing what will be written.
Known limitations
The usage endpoint used is not part of Anthropic's documented public API. It powers Claude Code itself, and could change or be removed at any time without notice — in which case the quota section will show "No usage data yet" until updated.
Works with Claude Code OAuth credentials only (Pro / Max / Team subscriptions). API-key-only users won't have a
~/.claude/.credentials.json, so the quota bars stay empty for them.Clicking a conversation identifies its tab by label, not by session. VS Code exposes no mapping between an editor tab and a Claude session (microsoft/vscode#158853), no API to activate a tab (#162446), and no API to raise a window (#51078); the Claude Code extension contributes no command targeting a
session_id(command inventory checked against 2.1.210). See Clicking a conversation for how the panel works around this, and what it can't do: two conversations whose titles share their first 24 characters are indistinguishable (the panel picks the one in the active editor group), and a conversation whose tab is closed everywhere is a no-op.The panel needs the Secondary Side Bar view container contribution, i.e. VS Code 1.106 or later (
engines.vscodeinpackage.json).A conversation whose title is a fallback (no
ai-titleyet — typically a brand-new conversation) can't be matched against a tab label, so "no match" proves nothing about it and it stays listed on that basis alone. Since 2.60.0 that only lasts until the workspace's tab store publishes a title for that session, which settles the question; while it hasn't, the row still leaves the list the old way: 4 h of inactivity, or an explicit tab close observed while the extension was running.The two animated icons deliberately ignore
prefers-reduced-motion. Chromium — hence this webview — derives that preference fromSPI_GETCLIENTAREAANIMATION, i.e. Windows' "Show animations" toggle, which is off on plenty of machines for performance reasons (blink-dev discussion). Honouring it froze thebusyarc into a static ring — the spinner carries the state, so cutting it removes information rather than toning it down. Neither icon moves across the screen (a 10 px rotation and an opacity fade), so there's no vestibular concern. If you want them still, the CSS lives inpanel.js.Read receipts are per-tab-label, like everything else here: two conversations whose titles share their first 24 characters would acknowledge each other. Same root cause as the click limitation above.
A reply read without ever leaving the tab or clicking the panel row stays bright (accepted trade-off): the strict read receipt (above) requires an observed act — a tab switch, or a click. Staring at the tab through the whole run, never touching anything else, never counts on its own; switching away and back, or a single click, dims it.
All tab↔conversation matching breaks silently if the official extension ever renames its
viewType. There's no direct way to detect the rename — only the symptom (a busy/waiting conversation with no Claude tab seen for a while), surfaced by the tab detection drift indicator. Until that fires (or is noticed), click-to-focus and tab-close removal simply stop doing anything, without an exception anywhere.The "1M context" and "interactive tool" heuristics are both dated snapshots, not derived facts, and will eventually be wrong for a model or tool this extension hasn't seen yet.
major ≥ 5 → 1M(hooks/model-id.js) assumes every future model generation ships with a 1M window by default, same as Sonnet 5 — a future ≥5 model that ships at 200k would have itsctx:%understated here (not overstated: the empirical guard in Model and context occupation already catches usage that crosses 200k regardless of this heuristic). The interactive-tool list (AskUserQuestion,ExitPlanMode) is hardcoded because nothing in atool_use's shape says it's interactive — a future tool that also hands control back to the user (another approval dialog, say) would keep showingbusyuntil the 60 sidle_promptfallback catches it, exactly the lag this feature exists to remove. Both are one-line additions inhooks/model-id.js/hooks/transcript.jswhen they go stale.The official Claude Code menu can briefly show the wrong model/effort for a batch-created conversation. It reads the persisted default model and calibrates whether it even shows an effort picker on that default, until the conversation's first turn resyncs it — e.g. launched with
opus, its menu shows no effort picker (as if it were still on a model that doesn't have one); launched withhaikuright after a manual switch toopus, its menu still shows an effort picker.ANTHROPIC_MODEL/CLAUDE_CODE_EFFORT_LEVELgovern the CLI process, not that webview's own display. This extension can't fix it — themodel · effortbadges on each conversation row (read from its transcript) are the real state, not the official menu.A batch's master conversation is found by an exact, one-shot lookup — or not at all. When a pasted
claude-convsblock is recognised, the panel looks for the conversation that produced it, once, at creation time: the block must appear verbatim (fences and\raside) in the assistant output of exactly one transcript, among the conversations currently listed and within the tail of each transcript that is read. Zero matches, several matches, an edited block, or a conversation that has already aged out of the list all end the same way — no master is set, and⌂ Set master…on the group header is there to do it by hand. Nothing runs in the background: transcripts are never watched or scanned for this.No effort selector for
haiku, noultracodeoption in the batch form. Claude Code has no notion of effort level forhaiku, so picking it disables the effort selector and noCLAUDE_CODE_EFFORT_LEVELis set for that task.ultracodeisn't offered at all: unlike model/effort, it isn't controllable via an environment variable on the CLI (checked against 2.1.217) — it's a session-scoped setting the official webview applies through an internal, not somethingeditor.opencan drive from outside. A selector that didn't actually do anything would be worse than no selector.
Language
The UI is in English and French, following VS Code's own display language (vscode.env.language) via the standard vscode-l10n mechanism. Any other display language falls back to English. The claude-convs block format itself (model:, effort:, stage:, group:, model/effort names) is not localized — it's a fixed contract, independent of the UI language, so a block written or pasted in any language always parses the same way.
License
MIT.



