Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`claude setup-token` is one, and so is every provider lich reads no plan
from.

- **A weekly quota window now says when it is being spent faster than its own
clock.** Forty percent of a week gone on day two and forty percent gone on day
six draw exactly the same bar, and only one of them runs out early. The gauge
marks the first: when spend runs at least fifteen points past the share of the
window that has elapsed, the window carries a marker, on the footer tooltip and
on the Settings screen alike. It is derived from the reading lich already takes
— no extra request, and no provider reports it — so it appears for every
provider that reports a weekly window, which today is Claude Code and Codex.
Only weekly windows are paced: a five-hour window turns over too fast for a
rate to mean anything. Nothing is marked in the first day after a reset, where
almost any use at all would read as far ahead of a share that is still near
zero.

- **A conflicting pull request now names the files it conflicts on.** GitHub
publishes one word — the pull request conflicts with its base — and never
which files, so the way to find out was opening the PR on github.com or a
Expand Down Expand Up @@ -58,6 +71,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
prompt is written once its agent is at a prompt to take it, rather than into a
setup script still installing the checkout. The Enter stays yours.

### Fixed

- **On Windows, an answer that named no ticket could close the wrong request.**
`lich reply "<answer>"` and `reply_to_session` without a ticket close the
oldest request delivered to that session — but "oldest" was read off the
clock, and Windows' only moves every ~15.6ms, so two requests handed to one
session inside a single tick carried the same timestamp and the tie was broken
at random. Roughly one time in ten, with two errands open, the answer went
home to the wrong sender, and both senders read a confident wrong report.
Delivery order is now counted rather than timed, so no clock's resolution can
flatten it. Linux and macOS have a fine enough clock that this was never
reachable there.

## [0.44.0] - 2026-09-03

### Added
Expand Down
12 changes: 12 additions & 0 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,18 @@ work when nobody knows it and that the call site never shows. The mechanism and
those logins and never writes them: it does not refresh the token, so an expired one reads as signed out until
the provider's own CLI rotates it. A reading is cached for five minutes because both endpoints rate-limit hard —
the number on screen is up to that old, and nothing on it says so.
- **The pace marker is borrowed calibration, and it is silent far more often than it is wrong**
(`internal/quota/pace.go`): the two numbers that decide when a weekly window is marked as spending ahead —
fifteen percentage points past the elapsed share, and no marking at all in the first twenty-four hours after
a reset — are the claude-swap project's measurements (`src/claude_swap/pace.py`, its issue #125), not lich's
own. Nothing here has been tuned against a lich user's accounts. The consequence to know before debugging a
marker that "never appears": a window ninety percent spent on the first day of its cycle is deliberately
unmarked, because just after a reset the elapsed share is near zero and almost any use at all would read as
far ahead. Only the weekly window is paced, so the five-hour one and Codex's monthly free-tier window carry
no marker however they are spent. The verdict is derived from the window's own reset time, which is the next
reset and never the start — the start is that time rolled back whole windows — so a provider that stops
reporting a reset time silently drops the marker rather than the gauge.

- **Two more fields of Claude's usage payload are read no further than measuring them**
(`internal/quota/claude.go`, `limits[].severity` and the top-level `extra_usage`/`spend` blocks): every
`severity` observed on a live account reads `"normal"`, so its scale — what a non-normal value looks like,
Expand Down
31 changes: 28 additions & 3 deletions frontend/src/components/QuotaGauge.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { TrendingUp } from "lucide-react"
import type { QuotaWindow } from "@/lib/api-types"
import { formatWindow, timeLeft } from "@/lib/quota/quota-format"
import { cn } from "@/lib/utils"
Expand Down Expand Up @@ -31,7 +32,12 @@ interface QuotaGaugeProps {
export function QuotaGauge({ window: quota, now, stacked }: QuotaGaugeProps) {
const length = formatWindow(quota.seconds)
const left = timeLeft(quota.resetsAt, now)
const label = length ? `${quota.label} · ${length}` : quota.label
const label = (
<>
<span className="truncate">{length ? `${quota.label} · ${length}` : quota.label}</span>
{quota.ahead && <AheadMark />}
</>
)
const bar = (
<span className="h-1.5 overflow-hidden rounded-full bg-muted">
<span
Expand All @@ -50,7 +56,7 @@ export function QuotaGauge({ window: quota, now, stacked }: QuotaGaugeProps) {
return (
<div className={cn("flex flex-col gap-1 text-xs", usageColor(quota.percent))}>
<div className="flex items-baseline justify-between gap-3">
<span className="text-muted-foreground">{label}</span>
<span className="flex min-w-0 items-center gap-1 text-muted-foreground">{label}</span>
<span className="tabular-nums">
{reading}
{left && ` · ${left}`}
Expand All @@ -62,14 +68,33 @@ export function QuotaGauge({ window: quota, now, stacked }: QuotaGaugeProps) {
}
return (
<div className={cn(gaugeGrid, "text-xs", usageColor(quota.percent))}>
<span className="truncate text-muted-foreground">{label}</span>
<span className="flex min-w-0 items-center gap-1 text-muted-foreground">{label}</span>
{bar}
<span className="text-right tabular-nums">{reading}</span>
<span className="text-right tabular-nums text-muted-foreground">{left}</span>
</div>
)
}

// AheadMark is the pace marker: this weekly window is being spent faster than
// its own clock runs. It sits in the label column rather than beside the
// percentage because the two numeric columns are fixed-width so that stacked
// gauges end in the same place, and it carries no colour of its own — the row
// already wears usageColor, and a hue here would say something the ramp does
// not.
function AheadMark() {
return (
<Tooltip>
<TooltipTrigger render={<span className="flex cursor-help items-center" />}>
<TrendingUp className="size-3 shrink-0" role="img" aria-label="Ahead of pace" />
</TooltipTrigger>
<TooltipContent side="top" className="border border-border bg-card text-foreground">
Spending ahead of this window's pace.
</TooltipContent>
</Tooltip>
)
}

// LockedReading replaces the bare percentage when the provider says a window
// is locked regardless of how full it reads — the percentage alone would look
// like headroom that spending cannot actually reach. The reason travels to a
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,9 @@ export interface QuotaWindow {
/** Present only when the provider says this window is locked regardless of
* its percentage; the reason travels to the tooltip verbatim. */
lockedReason?: string
/** lich's own reading, not the provider's: this weekly window is being spent
* faster than its own clock runs. Only weekly windows carry it. */
ahead?: boolean
}

/** internal/quota.Plan — one provider's quota reading. Windows are empty for
Expand Down
87 changes: 87 additions & 0 deletions internal/quota/pace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package quota

import "time"

// Pace is what a weekly window's percentage cannot say on its own: a window 40%
// spent on day two and one 40% spent on day six draw exactly the same bar, and
// only one of them is going to run out. Ahead marks the first — spend running
// far enough past the share of the window that has already elapsed that the
// rate, not the number, is the thing worth seeing.
const (
// aheadPoints is how far past the elapsed share spend has to run before the
// window is marked. Anything tighter marks an ordinary heavy afternoon in an
// otherwise light week, and a marker that is on most of the time is a marker
// nobody reads.
aheadPoints = 15
// aheadGrace is how long after a reset no window is marked. Just after one,
// the elapsed share is near zero and expected spend with it, so almost any
// use at all reads as far ahead — a false positive, not a warning. An
// `elapsed == 0` guard does not cover this: a reading taken an hour into a
// fresh window has a small elapsed, not a zero one.
aheadGrace = 24 * time.Hour
)

// markAhead marks w when its spend runs ahead of the window's own clock. Pure:
// `now` is a parameter so the verdict is testable and no reading depends on a
// clock read somewhere else.
//
// Only the weekly window is paced — the account-wide one and the model-scoped
// caps alike, which are weekly too. The five-hour window is left out on purpose:
// it turns over too fast for a rate to mean anything, since the first spend in a
// fresh window is "ahead" almost by definition, and the package's five-minute
// cache is a large fraction of five hours while it is nothing against a week.
//
// The 15-point threshold and the 24-hour grace are calibration measured by the
// claude-swap project (`src/claude_swap/pace.py`, its issue #125). The numbers
// are what is borrowed from there; the code is lich's own.
func markAhead(w Window, now time.Time) Window {
if w.Seconds != weeklyWindow {
return w
}
start, ok := windowStart(w, now)
if !ok {
return w
}
elapsed := now.Sub(start)
if elapsed < aheadGrace {
return w
}
expected := elapsed.Seconds() / float64(w.Seconds) * 100
w.Ahead = float64(w.Percent)-expected >= aheadPoints
return w
}

// windowStart derives when the current cycle began. ResetsAt is the only
// timestamp either provider reports and it is the *next* reset, never the start,
// so the start is that reset rolled back whole windows until it lands at or
// before now — which stays right however many cycles ahead a reset time sits,
// rather than assuming it is exactly one.
//
// False for a window with no parseable reset or no length: with no cycle to
// place, there is no pace to read, and an unmarked window is the honest answer.
func windowStart(w Window, now time.Time) (time.Time, bool) {
if w.Seconds <= 0 {
return time.Time{}, false
}
reset, err := time.Parse(time.RFC3339, w.ResetsAt)
if err != nil {
return time.Time{}, false
}
period := time.Duration(w.Seconds) * time.Second
cycles := int64(1)
if until := reset.Sub(now); until > period {
cycles = int64((until + period - 1) / period)
}
return reset.Add(-time.Duration(cycles) * period), true
}

// pace marks every window of a reading. One call site for every provider,
// because the verdict is derived from a Window alone: a provider that starts
// reporting a weekly window is paced without knowing this exists.
func pace(plans []Plan, now time.Time) {
for _, p := range plans {
for i, w := range p.Windows {
p.Windows[i] = markAhead(w, now)
}
}
}
127 changes: 127 additions & 0 deletions internal/quota/pace_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package quota

import (
"testing"
"time"
)

// paceNow is the clock every case here is written against; markAhead takes it
// as a parameter, which is the whole reason none of this needs a real one.
var paceNow = time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)

// resetIn spells a window's next reset as the providers report it: RFC 3339,
// that many seconds after paceNow.
func resetIn(seconds int) string {
return paceNow.Add(time.Duration(seconds) * time.Second).Format(time.RFC3339)
}

// The boundaries are pinned as literals on purpose, and every case sits one
// step either side of one: a test that reads aheadPoints or aheadGrace keeps
// passing after someone edits them, which is the exact change it exists to
// catch. 604800 is a week, 302400 half of one (so the expected share is a flat
// 50%), and 86400 the grace.
func TestMarkAhead(t *testing.T) {
tests := []struct {
name string
window Window
want bool
}{{
name: "weekly at half its clock, one point under the threshold",
window: Window{Seconds: 604800, Percent: 64, ResetsAt: resetIn(302400)},
want: false,
}, {
name: "weekly at half its clock, exactly on the threshold",
window: Window{Seconds: 604800, Percent: 65, ResetsAt: resetIn(302400)},
want: true,
}, {
name: "weekly at half its clock, one point over the threshold",
window: Window{Seconds: 604800, Percent: 66, ResetsAt: resetIn(302400)},
want: true,
}, {
name: "weekly spending under its own clock",
window: Window{Seconds: 604800, Percent: 40, ResetsAt: resetIn(302400)},
want: false,
}, {
name: "a second short of the grace, spent to the top",
window: Window{Seconds: 604800, Percent: 100, ResetsAt: resetIn(604800 - 86399)},
want: false,
}, {
name: "the grace exactly elapsed, spent to the top",
window: Window{Seconds: 604800, Percent: 100, ResetsAt: resetIn(604800 - 86400)},
want: true,
}, {
name: "the five-hour window is never paced",
window: Window{Seconds: 18000, Percent: 100, ResetsAt: resetIn(3600)},
want: false,
}, {
name: "the free tier's monthly window is not weekly, so it is not paced",
// 2592000 is Codex's 30-day window: past the grace, and half spent at
// half its clock, so only the weekly-only guard keeps it unmarked.
window: Window{Seconds: 2592000, Percent: 90, ResetsAt: resetIn(1296000)},
want: false,
}, {
name: "a model-scoped weekly cap is paced like the account-wide one",
window: Window{Label: "Fable", Seconds: 604800, Percent: 65, ResetsAt: resetIn(302400)},
want: true,
}, {
name: "a reset three cycles further out still places the current one",
window: Window{Seconds: 604800, Percent: 65, ResetsAt: resetIn(302400 + 3*604800)},
want: true,
}, {
name: "a reset already in the past marks nothing",
window: Window{Seconds: 604800, Percent: 100, ResetsAt: resetIn(-3600)},
want: false,
}, {
name: "no reset time, nothing to pace against",
window: Window{Seconds: 604800, Percent: 100},
want: false,
}, {
name: "an unparseable reset time marks nothing",
window: Window{Seconds: 604800, Percent: 100, ResetsAt: "next tuesday"},
want: false,
}, {
name: "a window whose length the provider never reported",
window: Window{Percent: 100, ResetsAt: resetIn(302400)},
want: false,
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := markAhead(tt.window, paceNow)
if got.Ahead != tt.want {
t.Errorf("Ahead = %v, want %v", got.Ahead, tt.want)
}
got.Ahead = tt.window.Ahead
if got != tt.window {
t.Errorf("markAhead changed more than the mark: %+v, want %+v", got, tt.window)
}
})
}
}

func TestPaceMarksEveryWindowOfEveryPlan(t *testing.T) {
plans := []Plan{{
Provider: "claude",
Windows: []Window{
{Label: "Session", Seconds: 18000, Percent: 90, ResetsAt: resetIn(3600)},
{Label: "Weekly", Seconds: 604800, Percent: 66, ResetsAt: resetIn(302400)},
},
}, {
Provider: "codex",
Windows: []Window{
{Label: "Weekly", Seconds: 604800, Percent: 40, ResetsAt: resetIn(302400)},
},
}}
pace(plans, paceNow)

want := []bool{false, true, false}
got := []bool{
plans[0].Windows[0].Ahead,
plans[0].Windows[1].Ahead,
plans[1].Windows[0].Ahead,
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("marks = %v, want %v", got, want)
}
}
}
8 changes: 6 additions & 2 deletions internal/quota/quota.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,16 @@ var accountVars = append([]string{
// provider does not report one. ResetsAt is RFC 3339, empty when unreported.
// Active is the provider's own verdict on which window is the binding one, when
// it reports one at all. LockedReason is non-empty only when the provider says
// this window cannot be spent past regardless of its percentage.
// this window cannot be spent past regardless of its percentage. Ahead is
// lich's own reading of the rate rather than the provider's — see pace.go.
type Window struct {
Label string `json:"label"`
Seconds int `json:"seconds"`
Percent int `json:"percent"`
ResetsAt string `json:"resetsAt,omitempty"`
Active bool `json:"active,omitempty"`
LockedReason string `json:"lockedReason,omitempty"`
Ahead bool `json:"ahead,omitempty"`
}

// Plan is one provider's quota reading. Provider is a providers.Registry id, so
Expand Down Expand Up @@ -250,7 +252,9 @@ func (s *Service) Plans(sessionID string) []Plan {
return cached.plans
}
plans := []Plan{s.claudePlan(account), s.codexPlan(account)}
s.cache[key] = reading{plans: plans, at: s.now()}
at := s.now()
pace(plans, at)
s.cache[key] = reading{plans: plans, at: at}
return plans
}

Expand Down
Loading