Imported from 444chak/usagedock (
AGENTS.md). Install upstream withnpx skills add 444chak/usagedock. Copyright stays with the author.
AGENTS.md
Context for an AI agent working on UsageDock with no prior knowledge of it. Read this before
touching anything. README.md explains what the app does for a user; this file explains how to
work on it safely.
What this is
A native Windows 11 dock, C# / .NET 10 / WPF, that shows Claude and Codex quota usage in a small always on top strip on the right, left or top edge of the screen. Windows only. No WebView, no Electron, no Tauri, no browser engine of any kind. Do not add one.
Build and test
The SDK may not be on PATH. On the original development machine dotnet resolves to
C:\Program Files\dotnet\dotnet.exe, which carries the runtimes but no SDK, and dotnet build
fails with "No .NET SDKs were found". The SDK lives under %LOCALAPPDATA%\Microsoft\dotnet. If
that is your situation:
export DOTNET_ROOT="$LOCALAPPDATA/Microsoft/dotnet"
export PATH="$DOTNET_ROOT:$PATH"
Then:
dotnet build -c Release # must finish with 0 errors and 0 warnings
dotnet test # must be all green
Tests redirect everything the app writes by setting USAGEDOCK_DATA_DIR from a module
initializer, so a run never touches the log or cache of a real installation. Keep that: the
concurrency tests provoke write failures on purpose, and those errors landing in a user log look
like real faults.
The build fails while the app is running. MSBuild cannot overwrite UsageDock.exe while a
running instance holds it, and the error is a confusing MSB3027 about apphost.exe. Stop it
first:
powershell -NoProfile -Command "Stop-Process -Name UsageDock -Force -ErrorAction SilentlyContinue"
Layout
src/UsageDock/
App.xaml.cs entry point, single instance mutex, wiring
MainWindow.xaml.cs the dock window: hit testing, hover strip, drag, fold
Models/ QuotaWindow, UsageSnapshot, AppConfig, ActivityState
Providers/ reading quotas from Claude and Codex
Services/ config, cache, logging, backoff, hooks, polling, file watching
Native/ Win32 interop, monitor enumeration, rounded rect hit testing
Tray/ notification area icon and menu
Ui/ usage ring, provider cell, hover card, provider marks
tests/UsageDock.Tests/
Invariants
These are the reasons the app is built the way it is. Breaking one is a regression even if everything still compiles and the tests pass.
1. No invisible surface may ever block a click. This is the single most important property.
- The dock window is exactly the size of the visible pill and nothing more.
- The hover strip (grip and fold arrow) is not reserved space. It is added to the window only
while the cursor is over the dock, growing away from the cells so they do not move, and removed
as soon as the cursor really leaves. The folded tab is its own exact size. All of the geometry
lives in
DockGeometry, which is pure and tested; keep it there. AllowsTransparencyis off. Do not turn it on to get a soft shadow or a glow. Windows 11 rounds the corners through DWM instead, and the four unpainted corner pixels are reported asHTTRANSPARENTfromWM_NCHITTESTinMainWindow.HitTestPoint.- The hover card is a separate window, sized to its own content, carrying
WS_EX_TRANSPARENTso every hit test falls straight through it. It is parked off screen until positioned; a WPF window with no explicit size gets a large default and will flash mid screen. - If you change the dock geometry, re-verify the corners. See "Verifying window behaviour".
2. Never leak a credential. Credential files are opened read only and never modified.
- No token value is ever logged, cached, written to
%APPDATA%\UsageDock\, or put into an error message. CodexAuthReaderdeliberately checks only for the presence of a token. Do not make it return one.ClaudeLocalUsageReaderdeliberately ignores theorgfield, which identifies the user.- Do not attempt to decrypt the Claude Desktop Electron store, the Windows Credential Manager, or any other protected credential store. If a token is not plainly available, fall back to a local source or report the situation honestly.
3. Never steal focus. WS_EX_NOACTIVATE, plus WM_MOUSEACTIVATE returning MA_NOACTIVATE.
Clicking the dock must not pull focus away from whatever the user is typing in.
4. Positioning is per monitor, in physical pixels, through SetWindowPos. Never use WPF
Left/Top for the dock or the card. The app is PerMonitorV2, and mixed DPI setups break the
moment logical coordinates creep in.
5. Hooks must stay trivially cheap. HookEntryPoint runs before WPF is constructed and must
stay that way, because Claude Code invokes it on every hook event. It currently costs 5 to 15 ms.
It must never throw, because that would fail the Claude Code turn it is attached to.
6. Hook installation is surgical. HookInstaller only ever adds or removes entries carrying
the --usagedock-hook marker. Users have hooks from other tools in the same settings.json and
they must survive. The file is backed up before the first write.
The same goes for the status line, with one twist: Claude Code allows a single one, so
StatusLineInstaller chains in front of an existing command instead of replacing it, keeping that
command verbatim inside its own so uninstall can restore it exactly. --usagedock-statusline
follows the hook rules of invariant 5: no WPF, never throws, and in --tee mode it writes the
payload through byte for byte before doing anything else, because the other status line is
waiting on it. The chained command falls back to cat if the executable is gone, so removing the
app never blanks the user's status line.
The MSIX container, read this before verifying anything
Claude Desktop from the Store is an MSIX package (Claude_<publisherhash>). Claude Code runs
inside it, so if you are an agent working in Claude Code, your shell is inside that container
too, and so is every process you start. Check with the parent chain: pwsh.exe <- claude.exe.
Inside the container, the three AppData folders (Roaming, Local, LocalLow) are
virtualised: reads are merged with, and writes redirected to,
%LOCALAPPDATA%\Packages\Claude_*\LocalCache\. The profile root (.claude, .codex,
.usagedock) and %LOCALAPPDATA%\Programs are not virtualised.
What this did to this project, over three reboots:
- Every "verification" run from the agent shell launched the dock inside the container, where
%APPDATA%\Claudeappears to exist. Windows launches it outside at login, where it does not. The bug only ever reproduced on the user's reboot, never in the agent's tests. - Reading
%APPDATA%\UsageDock\logsfrom the shell showed the container's copy of the log when one existed, not the real one, so the evidence itself was misleading. - The hooks run inside the container and wrote the activity file into the virtualised copy; the real dock read the real folder and stayed Idle forever.
Rules that follow:
- To launch the dock the way Windows does, go through explorer:
Start-Process explorer.exe -ArgumentList '"<path>\UsageDock.exe"'. Confirm with the parent process, which must beexplorer.exe. - A copy started from
bin\Debugorbin\Releasedeliberately does not re-point the Claude Code hooks or the status line feed at startup. It used to, and launching a build to verify something silently moved the installed copy's entries into the build folder. - Anything a hook writes and the dock reads must live under the profile root, never AppData.
- If a path "obviously exists" but a Windows-launched process says it does not, you are looking
at the virtualised view. Check
%LOCALAPPDATA%\Packages\Claude_*\LocalCache\.
Traps that already cost time
InvariantGlobalizationmust stayfalse. WPF resolvesXmlLanguagethroughCultureInfo,en-uscannot be created in invariant mode, and XAML parsing then throws at runtime with no build error. Symptom: a window silently never appears.UseWindowsFormsreplaces the implicit using set with the WinForms one. That is whyUsageDock.csprojremovesSystem.DrawingandSystem.Windows.Formsglobally and restores the usual base set. Without it,Brush,Color,Point,ApplicationandTimerare all ambiguous. WinForms is used only for the tray icon.App.xamlis aPage, not anApplicationDefinition, so the explicitMaininApp.xaml.csis the entry point and hook invocations can short circuit before WPF loads. Reverting that gives either "multiple entry points" or a slow hook.- Atomic writes need a unique temporary name per call. Concurrent hook processes and threads
collide otherwise. See
JsonIo.TryWriteFile. WindowFromPointdoes not honourHTTRANSPARENT. It reports the dock even where the window correctly reports itself transparent. To verify hit testing, sendWM_NCHITTESTdirectly.- Never trust
Environment.GetFolderPath. It returns an empty string when the shell cannot resolve a folder, which happens to a process launched early at Windows login.Path.Combinethen produces a relative path, so the app reads and writes next to its working directory, which for an autostarted process it cannot write to. BecauseLogswallows its own errors, the symptom is an app that runs for hours without a single log line, with one provider working (Codex, underUserProfile) and everything underApplicationDatasilently failing. This cost three reboots and two wrong hypotheses to find.AppPathsnow falls back through the environment variables and rejects anything not fully qualified; keep it that way, and keep the startup line that prints the resolved roots.
Where the data comes from
Both providers prefer local files over the network. Nothing is sent anywhere.
Codex reads %USERPROFILE%\.codex\sessions\**\rollout-*.jsonl. The CLI records the rate
limits the server reported into each session rollout as token_count events. Only the tail of the
most recent few files is scanned, because these files get large, and the record with the newest
line timestamp wins. Never order rollouts by file time: Codex keeps a session's file open, and
Windows leaves its last write time at creation until the handle closes, so a long VS Code session
at 100% looked older than a short one at 80%. auth.json is probed only for whether a session
exists.
Resets roll over locally. A reading only describes the window it was taken in, so once a
window's reset time passes it is shown at 0% without waiting for the source (QuotaWindow.AsOf,
applied when the coordinator publishes). This relies on every source reporting a reset that lies
after its own reading; keep that true for any new source.
The newest reading wins, across sources and across polls. UsageCoordinator.Reconcile never
replaces the reading on screen with an older one, even one a provider reports as Ok. A fallback
can be hours old: when the Claude usage endpoint throttled, the provider fell back to a desktop
sample from the day before, and the dock flipped between the live 28% and a rolled over 0% on
every other poll. Every source must therefore set UpdatedAt to when its reading was taken, not
to when it was read.
Claude has two sources, tried in this order:
%USERPROFILE%\.claude\.credentials.json, the OAuth token of the standalone CLI, used against the usage endpoint. Live, with exact reset times. Claude Desktop leaves those token fields empty; running the standaloneclaudein a terminal fills them. That first happened on the development machine on 2026-10-11 and the endpoint answered with live numbers, but it is still the least exercised path. Asked once a minute it answers 429 every other time, so it is asked at most everyClaudeUsageProvider.MinApiInterval(five minutes) whatever the poll interval; the file watchers trigger polls too, and must not turn into extra calls.- The local readings, whichever is newer (
ClaudeUsageProvider.Freshest):%USERPROFILE%\.usagedock\claude-statusline.json, therate_limitsobject Claude Code hands its status line, stored by--usagedock-statusline(opt in, tray menu). Exact resets, refreshed on every response, but only while Claude Code runs in a terminal: the Code tab of Claude Desktop never runs a status line, verified on 2026-10-11. Nothing else of the payload is stored; it names the session, the working directory and the transcript.plan-usage-history.json, written by Claude Desktop every fifteen minutes. Classic install:%APPDATA%\Claude\. Store (MSIX) install:%LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude\.AppPaths.ClaudeDesktopUsageFilechecks both on every access and the newest wins. Shape:{ "t": <epoch ms>, "org": "...", "u": { "fh": <5h percent>, "sd": <7d percent> } }. No token, no network. This is the source that actually runs day to day.
The history carries no reset timestamp. ClaudeLocalUsageReader.InferReset derives one: usage
only grows inside a window and drops when one rolls, so the start of the current run of non zero
samples locates the window. When it cannot be determined, report nothing rather than a guess.
Fifteen minutes is a hard ceiling on the freshness of the desktop history. UsageFileWatcher
removes the additional polling lag but cannot beat the sampling rate. Do not add faster polling
hoping to improve it; the status line feed is the way past it.
Activity states
Claude: Claude Code hooks write %APPDATA%\UsageDock\activity\claude.json. A stale Working or
Waiting record decays to Idle after five minutes, because a hook can be missed when a terminal is
killed.
Codex: no hook system, so the freshness of its session log is the signal. Both go through
ActivityService, so a better Codex signal can be dropped in without touching the UI.
Conventions
- Tests are xUnit, named as sentences, for example
A_stale_working_record_decays_to_idle. - Every parser must degrade rather than throw: malformed input yields fewer windows or null, never an exception. There are tests for this on each parser. Keep them.
- Comments explain why, not what. Do not narrate the code.
- Prefer drawing in
OnRenderover composing shapes for anything in the dock. The window is on screen permanently, so visual tree size and layout churn matter. - No new NuGet dependencies without a real reason. The app currently has none outside the SDK.
Verifying window behaviour
Compiling is not evidence. Run the app and check on screen. Worth re-verifying after any change to window geometry, window styles, or the hover card:
- Do it for each edge (right, left, top), folded and unfolded, and with the hover strip shown.
- Drive the mouse with a real move event (
mouse_eventafterSetCursorPos);SetCursorPosalone raises noWM_MOUSEMOVE, so WPF never sees the hover and the test proves nothing. - Send
WM_NCHITTESTto the dock at the four corner pixels. Each must answerHTTRANSPARENT(-1). The interior must answerHTCLIENT(1). - Enumerate the visible windows owned by the process. With the card hidden there must be exactly one, and it must be the size of the pill.
- Record the foreground window, click the dock, record it again. It must be unchanged.
- Check
%APPDATA%\UsageDock\logs\afterwards.Apphandles dispatcher exceptions and keeps running, so a broken binding or a XAML fault shows up only in the log. - Every poll logs its outcome per provider, but only when that outcome changes, so a steady state stays quiet while a provider that starts failing becomes visible. Do not remove it: an instance once ran for twenty three minutes showing stale numbers with a log that said nothing at all, and it could not be diagnosed afterwards.
