<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rehber Odhano</title>
    <description>The latest articles on DEV Community by Rehber Odhano (@rehberodhano).</description>
    <link>https://dev.to/rehberodhano</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4146020%2Ff3a6b3ab-9c0a-4456-92a4-83d8d953312c.jpg</url>
      <title>DEV Community: Rehber Odhano</title>
      <link>https://dev.to/rehberodhano</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rehberodhano"/>
    <language>en</language>
    <item>
      <title>Predicting when you'll hit your Claude usage limit, without scraping the page</title>
      <dc:creator>Rehber Odhano</dc:creator>
      <pubDate>Sun, 27 Sep 2026 21:45:14 +0000</pubDate>
      <link>https://dev.to/rehberodhano/predicting-when-youll-hit-your-claude-usage-limit-without-scraping-the-page-1llh</link>
      <guid>https://dev.to/rehberodhano/predicting-when-youll-hit-your-claude-usage-limit-without-scraping-the-page-1llh</guid>
      <description>&lt;p&gt;A few weeks ago I was in the middle of a refactor with Claude when the reply just stopped: "You've reached your session limit. Resets at 5:00 PM."&lt;/p&gt;

&lt;p&gt;I had no idea I was close.&lt;/p&gt;

&lt;p&gt;That's what bugged me. Claude's paid plans have two limits, a 5-hour session window and a 7-day weekly one, and claude.ai and Claude Code draw from the same pool. The numbers are sitting right there under Settings → Usage. But nothing ever says "at this rate you're done in 40 minutes." You find out when you hit it.&lt;/p&gt;

&lt;p&gt;So I built a browser extension that tells you. It's called Claude Usage Companion. It's free, it's open source, and nothing leaves your machine. This post is about the parts that turned out more interesting than I expected, most of which come down to building on an API that isn't documented and doesn't sit still.&lt;/p&gt;

&lt;h2&gt;
  
  
  62% doesn't tell you much
&lt;/h2&gt;

&lt;p&gt;A percentage on its own isn't that useful. 62% an hour into the window and 62% four and a half hours in are completely different situations. One means slow down, the other means you're fine.&lt;/p&gt;

&lt;p&gt;What I wanted was a single line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;At this pace, session runs out ~3:50 PM, before it resets.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7cbrqoe8a4fmaddllgml.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7cbrqoe8a4fmaddllgml.png" alt="Claude Usage Companion popup warning " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If I see that, I can do something about it. Switch to a lighter model, finish the risky part of the task first, or go get lunch. Most of the extension exists to make that one line trustworthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the data actually is
&lt;/h2&gt;

&lt;p&gt;There's no public API for this, so I opened DevTools on claude.ai and just used it for a while. Later I had Claude in Chrome drive a real session and capture the responses, so I'd have clean fixtures to build schemas from. Three sources turned up.&lt;/p&gt;

&lt;p&gt;The first is &lt;code&gt;GET /api/organizations/{org_id}/usage&lt;/code&gt;. It's what the Settings → Usage page calls: a &lt;code&gt;five_hour&lt;/code&gt; and a &lt;code&gt;seven_day&lt;/code&gt; window, each with a utilization percent and a reset time, with plain cookie auth. The catch is that claude.ai's frontend only calls it from that settings page. If you never open it, your browser never sees the data.&lt;/p&gt;

&lt;p&gt;The second is hiding in the chat stream. When you send a message, the reply streams back as server-sent events, and near the end, between &lt;code&gt;message_delta&lt;/code&gt; and &lt;code&gt;message_stop&lt;/code&gt;, there's a &lt;code&gt;message_limit&lt;/code&gt; event with the same two windows as fractions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"within_limit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"representativeClaim"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"five_hour"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"windows"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"5h"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"within_limit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"resets_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1788007200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"utilization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.29&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"7d"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"within_limit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"resets_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1788008400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"utilization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.55&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third is on claude.ai/code, where Claude Code sessions show up on the web. The session event log has &lt;code&gt;rate_limit_event&lt;/code&gt; entries mixed in with everything else.&lt;/p&gt;

&lt;p&gt;So there's a snapshot I can request whenever I want, and live updates that arrive with every reply while you're actually working. I use both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading fetch, not the page
&lt;/h2&gt;

&lt;p&gt;A lot of usage trackers read numbers out of the page's HTML. That works until the frontend gets redesigned, which happens often. The JSON behind the page changes less, so I capture that instead.&lt;/p&gt;

&lt;p&gt;The awkward part is Chrome's content script model. By default a content script runs in an "isolated world": it can see the DOM but not the page's JavaScript, so it can't see the page's &lt;code&gt;fetch&lt;/code&gt; calls. To wrap &lt;code&gt;fetch&lt;/code&gt;, a script has to run in the page's main world, and main-world scripts can't talk to the extension. So there are two scripts: one in the main world that captures, and one in the isolated world that just passes messages along.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;claude.ai tab
┌──────────────────────────────────────────────┐
│ MAIN world: hook                             │  wraps window.fetch,
│   /usage, completion SSE, code events        │  reads a *clone*
│        │ window.postMessage                  │
│ ISOLATED world: relay ───────────────────────┼──&amp;gt; runtime message
└──────────────────────────────────────────────┘          │
                                                           ▼
                                  background service worker
                                  validate → normalize → IndexedDB
                                  forecast, alerts, badge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hook is short. The one rule I cared about is that it can never break claude.ai:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;originalFetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;originalFetch&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Read a clone. The page gets the original, untouched.&lt;/span&gt;
    &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;handleResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;getRequestUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// A bug here can lose a data point.&lt;/span&gt;
    &lt;span class="c1"&gt;// It must never affect the page.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The chat stream isn't an &lt;code&gt;EventSource&lt;/code&gt;. claude.ai calls &lt;code&gt;fetch()&lt;/code&gt; and reads the body as a &lt;code&gt;ReadableStream&lt;/code&gt;, so if you go looking for &lt;code&gt;EventSource&lt;/code&gt; you'll find nothing. You have to read the cloned body yourself and split it into SSE frames, keeping a buffer because a frame can arrive split across two chunks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getReader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SseFrameBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(;;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;sse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;handleFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;sse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nf"&gt;handleFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// handleFrame ignores everything except 'message_limit'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Claude Code event log needed more care, because most of what's in it is your actual conversation: &lt;code&gt;user&lt;/code&gt; and &lt;code&gt;assistant&lt;/code&gt; messages. The hook throws away everything except &lt;code&gt;rate_limit_event&lt;/code&gt; before anything leaves the page, so conversation text never reaches the extension's storage at all.&lt;/p&gt;

&lt;p&gt;Then there's the settings-page problem. Once the extension has seen your org ID in a usage or chat request, the background worker calls &lt;code&gt;/usage&lt;/code&gt; itself every 5 minutes, using the session cookie you already have. That way the bars stay fresh even if you never open Settings again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API changed while I was using it
&lt;/h2&gt;

&lt;p&gt;I captured the same account's &lt;code&gt;/usage&lt;/code&gt; response twice, three days apart. In between, a &lt;code&gt;locked_reason&lt;/code&gt; field appeared on each window that hadn't been there before, not even as &lt;code&gt;null&lt;/code&gt;. A new top-level key showed up too, always &lt;code&gt;null&lt;/code&gt; so far. And some sub-objects, like the spend limit, flipped between a full object and &lt;code&gt;null&lt;/code&gt; depending on whether usage credits were turned on.&lt;/p&gt;

&lt;p&gt;If I'd written a strict schema from the first capture, it would have accepted the second one fine. If I'd written it from the second, it would have rejected the first. So I settled on three rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Any field I haven't seen in every capture is optional, and unknown keys are allowed through.&lt;/li&gt;
&lt;li&gt;Everything goes through &lt;code&gt;zod&lt;/code&gt;'s &lt;code&gt;safeParse&lt;/code&gt;. If a payload doesn't match, it gets logged and skipped. Nothing throws.&lt;/li&gt;
&lt;li&gt;Every schema is written against a real, anonymized capture that's checked into the repo, never against what I assume the API returns.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This protects against frontend redesigns, not against the API itself changing. If the response format changes enough, the extension stops updating until I ship a fix. I'd rather it go quiet than show wrong numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The forecast is a straight line, on purpose
&lt;/h2&gt;

&lt;p&gt;It's least squares through recent snapshots. The only real decision is which snapshots to use.&lt;/p&gt;

&lt;p&gt;A window resets: the percentage drops back toward zero. Fit a line across that drop and the result is garbage. So I only use the current run. Walk backward from the newest point and stop at the first place the percentage went down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// history sorted oldest → newest&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;runStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// a drop means the window reset&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;percent&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;percent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;runStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;runStart&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// can't fit a line to one point&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that it's ordinary least squares on hours-since-run-start against percent, solved for where the line reaches 100. Confidence is just sample size: fewer than 3 points is low, 3 or more is medium, and 5 or more spread over at least an hour is high. It's not a real confidence interval, and the code comments say so.&lt;/p&gt;

&lt;p&gt;I didn't try anything smarter because usage is bursty. You prompt hard for 20 minutes, then read code for 40. Nothing I could build would predict when you'll open the next chat. A straight line answers the question you actually have ("at the pace I've been going, do I make it?"), and when it's wrong you can see why.&lt;/p&gt;

&lt;p&gt;One edge case: a tiny but positive rate, where usage is barely moving, projects the "full" time thousands of years out, past what a JavaScript &lt;code&gt;Date&lt;/code&gt; can hold. &lt;code&gt;new Date()&lt;/code&gt; doesn't throw on that; it quietly gives you an Invalid Date, and then &lt;code&gt;toISOString()&lt;/code&gt; does throw. That would crash the dashboard, so there's a check for it now.&lt;/p&gt;

&lt;h3&gt;
  
  
  Knowing when to shut up
&lt;/h3&gt;

&lt;p&gt;This turned out to matter more than the math. A warning that fires all the time gets ignored.&lt;/p&gt;

&lt;p&gt;The popup only shows a headline when something needs attention: a limit you've already hit, or a forecast that says you'll run out early. For the forecast to count, three things have to be true. It has at least medium confidence. At least 10% of the window is used, because 2% in the first five minutes technically projects to "full in four hours," which is useless. And the projected run-out lands at least 30 minutes before the reset; if it only beats the reset by a few minutes, it's not worth interrupting you. Otherwise the popup just shows the bars.&lt;/p&gt;

&lt;p&gt;There's also an optional notification for the same forecast, off by default in Settings. It fires once per limit window. Deduplicating that was fiddlier than I expected: &lt;code&gt;/usage&lt;/code&gt; and the chat stream report the same reset time with slightly different precision, so the dedupe key rounds the reset time to the hour. Otherwise the same warning would fire twice. Separately, plain threshold notifications at 80% and 95% are on by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Claude Code side
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0r7xbfhfleushjam1q05.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0r7xbfhfleushjam1q05.png" alt="Usage &amp;amp; Forecast dashboard showing 7 days of session and weekly usage history, with a warning that the session limit will be reached before it resets" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A browser can't see your terminal. But Claude Code keeps session transcripts in &lt;code&gt;~/.claude/projects/**/*.jsonl&lt;/code&gt;, with token usage and the model on each response and the working directory on each line. So there's an optional local daemon, a small Node + Hono server, that reads them. It uses &lt;a href="https://github.com/ccusage/ccusage" rel="noopener noreferrer"&gt;ccusage&lt;/a&gt; (in &lt;code&gt;--offline&lt;/code&gt; mode, so it never fetches pricing data) for token totals by project and model, and reads the transcripts directly for full-text search across all your sessions, with a copy-paste &lt;code&gt;claude --resume &amp;lt;id&amp;gt;&lt;/code&gt; command. It also warns you before Claude Code's default 30-day cleanup deletes an old session, with a one-click markdown export.&lt;/p&gt;

&lt;p&gt;The part I was most curious about was how much of my weekly limit was the CLI versus chat. Nothing tells you. My estimate is crude:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tokens per 1% of weekly ≈ CLI tokens in the window
                          / how much the weekly % went up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that ratio you can turn a project's token count into something like "~15% of this week." It has a real flaw: if you also chat on claude.ai during the same period, that usage gets counted as CLI, which inflates the ratio. There's no way to separate the two with this data, so the UI calls it a rough estimate right next to the number.&lt;/p&gt;

&lt;p&gt;There's also a statusline script for Claude Code's &lt;code&gt;statusLine&lt;/code&gt; setting. Claude Code already passes &lt;code&gt;rate_limits.five_hour&lt;/code&gt; and &lt;code&gt;rate_limits.seven_day&lt;/code&gt; to statusline scripts on stdin (on Pro/Max, after your first message), so the session and weekly percentages work without the daemon at all. If the daemon is running, it adds today's token count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Localhost isn't private by default
&lt;/h2&gt;

&lt;p&gt;It's easy to assume a server on &lt;code&gt;127.0.0.1&lt;/code&gt; is safe. It isn't, automatically, because any website you visit can &lt;code&gt;fetch('http://127.0.0.1:4317/...')&lt;/code&gt;. So the daemon only binds to 127.0.0.1, rejects any request whose Origin isn't a browser extension, and wants a bearer token on everything except &lt;code&gt;/health&lt;/code&gt; and the one-time &lt;code&gt;/pair&lt;/code&gt; endpoint. Requests with no Origin at all, like the statusline script, get through the Origin check but still need the token.&lt;/p&gt;

&lt;p&gt;Pairing used to mean copying a token out of the terminal and pasting it into the options page, which is a lot to ask on a first run. Now &lt;code&gt;claude-usage-daemon install&lt;/code&gt; generates a fresh random token, stores it in a &lt;code&gt;0600&lt;/code&gt; file, and opens &lt;code&gt;/pair&lt;/code&gt;, which hands the token out exactly once. The extension checks it every minute, grabs the token, and from then on &lt;code&gt;/pair&lt;/code&gt; returns 403 until you run &lt;code&gt;install&lt;/code&gt; again. Anyone on your machine right after install could technically grab it first, but that was already true of copy-paste.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few other things that bit me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I was logging with &lt;code&gt;console.debug&lt;/code&gt;, and Chrome hides "Verbose" messages by default. For a while I thought my hook wasn't running at all.&lt;/li&gt;
&lt;li&gt;The Chrome Web Store rejected one of my submissions because the manifest asked for the &lt;code&gt;storage&lt;/code&gt; permission and nothing used it. Everything is in IndexedDB, which doesn't need a permission. It was a leftover from the project template.&lt;/li&gt;
&lt;li&gt;The store's manifest validator also won't accept a wildcard port in &lt;code&gt;host_permissions&lt;/code&gt;, even though Chrome accepts one when you load the extension unpacked. So the daemon's port, 4317, is hardcoded.&lt;/li&gt;
&lt;li&gt;The daemon pushes a "something changed" event to the extension over server-sent events. &lt;code&gt;EventSource&lt;/code&gt; can't set an &lt;code&gt;Authorization&lt;/code&gt; header, so that one route also accepts the token as a query parameter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;It can't see the Claude desktop app, though that usage still shows up in the shared session and weekly totals. API console usage is out of scope. Per-model limits aren't shown, because none of the responses I've captured have included them. And everything depends on endpoints Anthropic never promised to keep stable. That's the real risk, and careful parsing doesn't remove it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Chrome / Edge: &lt;a href="https://chromewebstore.google.com/detail/chjbjdabpficejgogljohhlobfaehepl" rel="noopener noreferrer"&gt;Chrome Web Store&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source (MIT): &lt;a href="https://github.com/RehberOdhano/headroom" rel="noopener noreferrer"&gt;github.com/RehberOdhano/headroom&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Optional daemon for Claude Code (Node 20+):
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @rehberodhano/claude-usage-companion-daemon
claude-usage-daemon &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After installing the extension, open claude.ai once (Settings → Usage, or just send a message) so it can find your account. After that it updates on its own.&lt;/p&gt;

&lt;p&gt;It's a side project, not affiliated with Anthropic. If the first-run setup confuses you, or the forecast is way off for how you work, I'd like to hear about it, in the comments here or as a GitHub issue.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>claude</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
