Skip to content

Commit c1d49ce

Browse files
committed
feat(usage): the live footer warns about the Claude subscription wall
The Anthropic OAuth headers — unified 5h/7d utilisation, which window is binding, the subscription status, the reset — were already captured from every response into rateLimits, but only `/usage quota` ever showed them. A Pro/Max user learned about the weekly or 5-hour wall when a turn stalled. Now updateFooter looks up the active provider's snapshot and appends footerQuotaSegment(snap): `⚠ 7d 8% · resets 14:05` once the binding window is below 15% (FOOTER_WARN_FRACTION), or whenever the status is anything but "allowed" (named, e.g. "allowed warning"); empty while healthy, and always empty for providers that carry no such headers. shortReset renders Anthropic's epoch-seconds reset as HH:MM today or MM-DD HH:MM later, and leaves unwieldy raw values out of the footer. No slow-lane retry loop — awareness only. From the can1357/oh-my-pi study (session/anthropic-slow-mode.ts).
1 parent 2a3c0aa commit c1d49ce

5 files changed

Lines changed: 96 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Folded from each message's `usage.cost`, which pi already computes. It survives
2020

2121
When [`@pify/subagent`](https://github.com/pifydev/subagent), [`@pify/swarm`](https://github.com/pifydev/swarm) or [`@pify/workflow`](https://github.com/pifydev/workflow) run child agents, those children are separate in-memory sessions whose spend never reaches this branch — a session that delegates used to look cheaper than it was. Those packages now add every child message's cost to a process-wide tally, and the footer shows it as a separate ` · agents $0.12` beside the session's own figure (the `/usage` session block has the same line, with tokens). It is kept apart on purpose: the session numbers stay what the branch says, and the agents figure is what the delegation cost on top. The tally survives `/reload` and starts over with a new session.
2222

23+
On a Claude subscription (OAuth Pro/Max) the footer also carries the wall you are approaching: the headers `/usage quota` reads were already captured on every response, but only that command showed them, so the weekly or 5-hour limit used to arrive as a stalled turn. Now a `⚠ 7d 8% · resets 14:05` segment appears once the binding window is below 15%, or whenever Anthropic marks the subscription as anything but allowed, and stays out of the way otherwise. Other providers carry no such headers, so they never show it.
24+
2325
The gauge on the right is how full the context window is — the third mid-session question, next to tokens and cost. It was computed for the `/usage` dashboard but shown only there; now it is live. It appears once there is a window to measure against (so not under `-p`) and turns to `⚠` past 90%, the point where "how full" stops being trivia and becomes a decision. For the breakdown of what filled it, run `/context`.
2426

2527
## `/usage`

‎extensions/usage.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { childSpendTotal, onChildSpend, resetChildSpend } from "../src/child-cos
3535
import { QUOTA_PROVIDERS, fetchQuota, quotaReport, type QuotaResult } from "../src/quota.ts";
3636
import { redact } from "../src/redact.ts";
3737
import { scanSessions } from "../src/sessions.ts";
38-
import { parseRateLimit, formatRateLimit, type RateLimitSnapshot } from "../src/ratelimit.ts";
38+
import { parseRateLimit, formatRateLimit, type RateLimitSnapshot, footerQuotaSegment } from "../src/ratelimit.ts";
3939
import { emptyTotals, isRecord, type UsageTotals } from "../src/types.ts";
4040

4141
type UiContext = ExtensionContext;
@@ -83,7 +83,10 @@ export default function usage(pi: ExtensionAPI) {
8383
return;
8484
}
8585
const gauge = contextGauge(contextInfo(ctx).pct);
86-
ctx.ui.setStatus("usage", gauge ? `${base} · ${gauge}` : base);
86+
// The subscription wall, from the headers already captured for this
87+
// provider: quiet while healthy, a short warning once it is close.
88+
const quota = footerQuotaSegment(rateLimits.get((ctx.model as { provider?: string } | null)?.provider ?? ""));
89+
ctx.ui.setStatus("usage", [base, gauge, quota].filter(Boolean).join(" · "));
8790
}
8891

8992
/**

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pify/usage",
3-
"version": "0.10.0",
3+
"version": "0.11.0",
44
"description": "Token and cost reporting for pi sessions: live footer, local-history dashboard with per-project costs, opt-in provider quota",
55
"keywords": [
66
"pi-package",

‎src/ratelimit.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,46 @@ function formatReset(snap: RateLimitSnapshot): string {
166166
return raw;
167167
}
168168

169+
/** A reset time short enough for the footer: HH:MM today, otherwise MM-DD HH:MM; "" when unknown or unwieldy. */
170+
export function shortReset(snap: RateLimitSnapshot, now: number = Date.now()): string {
171+
const raw = snap.resets;
172+
if (!raw) return "";
173+
if (snap.source === "anthropic" && /^\d{9,}$/.test(raw)) {
174+
const d = new Date(Number.parseInt(raw, 10) * 1000);
175+
if (!Number.isFinite(d.getTime())) return "";
176+
const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
177+
const sameDay = new Date(now).toDateString() === d.toDateString();
178+
return sameDay ? hm : `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${hm}`;
179+
}
180+
return raw.length <= 12 ? raw : "";
181+
}
182+
183+
/** Below this share of the binding window left, the footer starts warning. */
184+
export const FOOTER_WARN_FRACTION = 0.15;
185+
186+
/**
187+
* The footer's quota warning, or "" while the session is healthy. A Claude
188+
* subscription user used to learn about the 5h/7d wall only when a turn
189+
* stalled, or by opening `/usage quota` — the headers were captured either
190+
* way. This shows the binding window once it is nearly gone, or whenever
191+
* Anthropic marks the subscription as anything but "allowed". Non-Anthropic
192+
* providers carry none of these fields, so it stays empty for them.
193+
*/
194+
export function footerQuotaSegment(snap: RateLimitSnapshot | null | undefined, now: number = Date.now()): string {
195+
if (!snap) return "";
196+
const binding =
197+
snap.unifiedBinding ?? (snap.unified7dRemaining !== null ? "7d" : snap.unified5hRemaining !== null ? "5h" : null);
198+
const remaining = binding === "7d" ? snap.unified7dRemaining : binding === "5h" ? snap.unified5hRemaining : null;
199+
const walled = Boolean(snap.unifiedStatus && snap.unifiedStatus !== "allowed");
200+
const low = remaining !== null && remaining < FOOTER_WARN_FRACTION;
201+
if (!walled && !low) return "";
202+
const parts = [`⚠ ${binding ?? "subscription"}`];
203+
if (remaining !== null) parts.push(`${Math.max(0, Math.round(remaining * 100))}%`);
204+
if (walled) parts.push(snap.unifiedStatus!.replace(/_/g, " "));
205+
const reset = shortReset(snap, now);
206+
return reset ? `${parts.join(" ")} · resets ${reset}` : parts.join(" ");
207+
}
208+
169209
/** One human line for the /usage quota report; null when nothing was captured. */
170210
export function formatRateLimit(snap: RateLimitSnapshot | null): string | null {
171211
if (!snap) return null;

‎test/usage.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
quotaReport,
2323
} from "../src/quota.ts";
2424
import { checkUrl, controlledGetJson } from "../src/http.ts";
25+
import { footerQuotaSegment, parseRateLimit, shortReset } from "../src/ratelimit.ts";
2526
import { redact } from "../src/redact.ts";
2627
import { emptyTotals, type UsageRecord } from "../src/types.ts";
2728

@@ -449,3 +450,50 @@ test("footer and session block show what child agent sessions spent, apart from
449450
assert.match(block, /agents \$0\.12 · 30\.0k tok in child sessions, on top of the above/);
450451
assert.ok(!sessionBlock(totals, null).includes("agents"));
451452
});
453+
454+
test("the footer warns about the subscription wall only when it is close or the status is not allowed", () => {
455+
const now = Date.UTC(2026, 8, 26, 10, 0, 0);
456+
const at = (h: Record<string, string>) => parseRateLimit("anthropic", h, now);
457+
// Healthy: plenty of both windows, status allowed → silence.
458+
const healthy = at({
459+
"anthropic-ratelimit-unified-5h-utilization": "0.2",
460+
"anthropic-ratelimit-unified-7d-utilization": "0.4",
461+
"anthropic-ratelimit-unified-representative-claim": "7d",
462+
"anthropic-ratelimit-unified-status": "allowed",
463+
});
464+
assert.equal(footerQuotaSegment(healthy, now), "");
465+
// The binding 7d window nearly gone → a short warning with its share left.
466+
const low = at({
467+
"anthropic-ratelimit-unified-5h-utilization": "0.2",
468+
"anthropic-ratelimit-unified-7d-utilization": "0.92",
469+
"anthropic-ratelimit-unified-representative-claim": "7d",
470+
"anthropic-ratelimit-unified-status": "allowed",
471+
});
472+
assert.equal(footerQuotaSegment(low, now), "⚠ 7d 8%");
473+
// A non-allowed status warns whatever the numbers say, and names it.
474+
const walled = at({
475+
"anthropic-ratelimit-unified-7d-utilization": "0.5",
476+
"anthropic-ratelimit-unified-status": "allowed_warning",
477+
});
478+
assert.equal(footerQuotaSegment(walled, now), "⚠ 7d 50% allowed warning");
479+
// Nothing captured, or another provider's snapshot, stays quiet.
480+
assert.equal(footerQuotaSegment(null), "");
481+
assert.equal(footerQuotaSegment(parseRateLimit("openai", { "x-ratelimit-remaining-requests": "5" }, now)), "");
482+
});
483+
484+
test("shortReset renders Anthropic's epoch seconds compactly and leaves long values out of the footer", () => {
485+
const now = Date.UTC(2026, 8, 26, 10, 0, 0);
486+
const soon = parseRateLimit(
487+
"anthropic",
488+
{ "anthropic-ratelimit-unified-7d-utilization": "0.9", "anthropic-ratelimit-unified-reset": String(Math.floor(now / 1000) + 3600) },
489+
now,
490+
)!;
491+
assert.match(shortReset(soon, now), /^\d{2}:\d{2}$/);
492+
const later = parseRateLimit(
493+
"anthropic",
494+
{ "anthropic-ratelimit-unified-7d-utilization": "0.9", "anthropic-ratelimit-unified-reset": String(Math.floor(now / 1000) + 3 * 86400) },
495+
now,
496+
)!;
497+
assert.match(shortReset(later, now), /^\d{2}-\d{2} \d{2}:\d{2}$/);
498+
assert.match(footerQuotaSegment(soon, now), /^⚠ 7d 10% · resets \d{2}:\d{2}$/);
499+
});

0 commit comments

Comments
 (0)