715420d0a9
usage-window.py stores a calibrated window quota and emits an explicit verdict, so a run can no longer park on an uncalibrated raw token count. Milestone assess is scoped; full suite moves to the gated push points. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YUXS63P1WCdC6bEKWcnAE
347 lines
13 KiB
Python
Executable File
347 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Estimate token usage inside the rolling 5-hour limit window.
|
|
|
|
The real quota is server-side and nothing local states it. What IS local is
|
|
every assistant message Claude Code has written, with a timestamp and a usage
|
|
block, in ~/.claude/projects/**/*.jsonl. Summing those over a rolling window
|
|
gives a usable proxy: not the quota, but a consistent measure of how hard the
|
|
window has been worked, plus when the oldest tokens in it age out.
|
|
|
|
A RAW TOKEN COUNT IS NOT A SIGNAL. It is a numerator. "4.8 million" sounds
|
|
alarming and means nothing without the denominator — on a large plan that can
|
|
be 30% of the window. Decisions about backing off MUST be made on percentage,
|
|
which means the quota has to be calibrated at least once. See --calibrate.
|
|
|
|
SCOPE — read this before trusting the number:
|
|
|
|
Counted: every Claude Code session on THIS machine, all projects, not
|
|
just the session asking. Parallel sessions in other repos land
|
|
in the same window and this picks them up.
|
|
NOT counted: Claude Code on any other machine, claude.ai web usage, direct
|
|
API calls, and anything else that never writes a transcript here.
|
|
|
|
So the figure is a floor, never a ceiling. Real usage is this or higher, and
|
|
the gap is however much the account is being used elsewhere. Size your margin
|
|
accordingly: if the user works on several machines, treat a "comfortable"
|
|
reading with suspicion.
|
|
|
|
Treat the number as a trend, not a truth. It is here so a long autonomous run
|
|
can back off *before* an agent dies mid-task, rather than discovering the limit
|
|
by being killed by it. The reactive backstop still matters: an agent that dies
|
|
on a limit error is telling you the truth this script only estimates.
|
|
|
|
Usage:
|
|
usage-window.py # human summary
|
|
usage-window.py --json # machine readable
|
|
usage-window.py --hours 5 # window size (default 5)
|
|
usage-window.py --quota 16e6 # one-off quota override
|
|
usage-window.py --calibrate 30 # "Claude Code says I'm at 30%" -> store implied quota
|
|
|
|
Calibration:
|
|
Ask the user what percentage their /status (or the Claude Code UI) reports,
|
|
then run --calibrate with it. The implied quota is written to
|
|
~/.claude/nightshift-quota.json and used by every later run. Re-calibrate
|
|
if the plan changes. One data point beats zero; two beats one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
PROJECTS = Path.home() / ".claude" / "projects"
|
|
QUOTA_FILE = Path.home() / ".claude" / "nightshift-quota.json"
|
|
|
|
# Percentage bands. These are about RISK TO IN-FLIGHT WORK, not thrift — the
|
|
# window refills continuously, and an unused window is wasted capacity, not
|
|
# saved money. The only thing being avoided is agents dying mid-task.
|
|
BAND_CLEAR = 60.0 # below this: dispatch freely
|
|
BAND_WRAP = 80.0 # below this: keep going, prefer shorter tasks
|
|
# at or above BAND_WRAP: finish what's running, commit, push, park
|
|
|
|
|
|
def parse_ts(value: str | None) -> datetime | None:
|
|
if not value:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def billable(usage: dict) -> int:
|
|
"""Tokens that plausibly count against a quota.
|
|
|
|
Cache reads are excluded: they are the cheap path and counting them would
|
|
make a long cached session look far heavier than it is. Cache *creation* is
|
|
included, because writing the cache is real work.
|
|
"""
|
|
return (
|
|
int(usage.get("input_tokens") or 0)
|
|
+ int(usage.get("cache_creation_input_tokens") or 0)
|
|
+ int(usage.get("output_tokens") or 0)
|
|
)
|
|
|
|
|
|
def load_quota() -> tuple[int | None, str | None]:
|
|
"""Calibrated window quota, if the user has ever supplied one.
|
|
|
|
Precedence: --quota (handled by caller) > env > stored file > unknown.
|
|
Returns (quota, source) so the output can say where the number came from —
|
|
an uncalibrated guess must never masquerade as a measurement.
|
|
"""
|
|
env = os.environ.get("NIGHTSHIFT_WINDOW_QUOTA")
|
|
if env:
|
|
try:
|
|
return int(float(env)), "env NIGHTSHIFT_WINDOW_QUOTA"
|
|
except ValueError:
|
|
pass
|
|
try:
|
|
data = json.loads(QUOTA_FILE.read_text(encoding="utf-8"))
|
|
quota = int(data["windowTokenQuota"])
|
|
at = data.get("calibratedAt", "unknown date")
|
|
return quota, f"calibrated {at[:10]}"
|
|
except (OSError, KeyError, ValueError, TypeError):
|
|
return None, None
|
|
|
|
|
|
def save_quota(quota: int, observed_tokens: int, observed_pct: float) -> None:
|
|
QUOTA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
QUOTA_FILE.write_text(
|
|
json.dumps(
|
|
{
|
|
"windowTokenQuota": quota,
|
|
"calibratedAt": datetime.now(timezone.utc).isoformat(),
|
|
"observedTokens": observed_tokens,
|
|
"observedPercent": observed_pct,
|
|
"note": (
|
|
"Implied quota = observedTokens / (observedPercent/100). "
|
|
"Derived from what Claude Code reported at one moment, so it "
|
|
"inherits this script's blind spots (other machines, web, API). "
|
|
"Re-run --calibrate after a plan change or if parking decisions "
|
|
"start feeling wrong."
|
|
),
|
|
},
|
|
indent=2,
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def collect(hours: float) -> dict:
|
|
now = datetime.now(timezone.utc)
|
|
cutoff = now - timedelta(hours=hours)
|
|
|
|
total = 0
|
|
output = 0
|
|
messages = 0
|
|
oldest: datetime | None = None
|
|
newest: datetime | None = None
|
|
per_project: dict[str, int] = {}
|
|
|
|
if not PROJECTS.is_dir():
|
|
return {
|
|
"error": f"no transcript directory at {PROJECTS}",
|
|
"windowHours": hours,
|
|
}
|
|
|
|
for path in PROJECTS.glob("*/*.jsonl"):
|
|
# Cheap skip: a file untouched since the cutoff cannot contribute.
|
|
try:
|
|
if datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) < cutoff:
|
|
continue
|
|
except OSError:
|
|
continue
|
|
|
|
project = path.parent.name
|
|
try:
|
|
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
for line in handle:
|
|
if '"usage"' not in line:
|
|
continue
|
|
try:
|
|
entry = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
stamp = parse_ts(entry.get("timestamp"))
|
|
if stamp is None or stamp < cutoff:
|
|
continue
|
|
usage = (entry.get("message") or {}).get("usage")
|
|
if not isinstance(usage, dict):
|
|
continue
|
|
|
|
tokens = billable(usage)
|
|
if tokens <= 0:
|
|
continue
|
|
|
|
total += tokens
|
|
output += int(usage.get("output_tokens") or 0)
|
|
messages += 1
|
|
per_project[project] = per_project.get(project, 0) + tokens
|
|
oldest = stamp if oldest is None or stamp < oldest else oldest
|
|
newest = stamp if newest is None or stamp > newest else newest
|
|
except OSError:
|
|
continue
|
|
|
|
# When the oldest tokens in the window age out. This is the soonest the
|
|
# window measurably loosens; it is NOT a quota reset time, which only the
|
|
# server knows.
|
|
ages_out_in = None
|
|
if oldest is not None:
|
|
ages_out_in = max(0, int(((oldest + timedelta(hours=hours)) - now).total_seconds()))
|
|
|
|
return {
|
|
"windowHours": hours,
|
|
"tokens": total,
|
|
"outputTokens": output,
|
|
"messages": messages,
|
|
"projects": dict(sorted(per_project.items(), key=lambda kv: -kv[1])),
|
|
"oldestInWindow": oldest.isoformat() if oldest else None,
|
|
"newestInWindow": newest.isoformat() if newest else None,
|
|
"oldestAgesOutInSeconds": ages_out_in,
|
|
"now": now.isoformat(),
|
|
}
|
|
|
|
|
|
def add_verdict(report: dict, quota: int | None, source: str | None) -> dict:
|
|
"""Attach percent-of-quota and an explicit act-on-this verdict.
|
|
|
|
Without a calibrated quota there is NO verdict — deliberately. An earlier
|
|
version of this tool emitted only a raw count, and the reading agent
|
|
invented a magnitude threshold to compare it against, then parked a run at
|
|
what turned out to be 30% of the window. Saying "unknown" is strictly better
|
|
than handing back a number that invites a made-up denominator.
|
|
"""
|
|
report["quota"] = quota
|
|
report["quotaSource"] = source
|
|
if not quota or quota <= 0:
|
|
report["percentUsed"] = None
|
|
report["verdict"] = "unknown"
|
|
report["verdictReason"] = (
|
|
"No calibrated quota. Percentage is unknown, so the raw token count says "
|
|
"NOTHING about how close the window is. Do not park on magnitude. "
|
|
"Ask the user what percent Claude Code reports, then run --calibrate."
|
|
)
|
|
return report
|
|
|
|
pct = 100.0 * report["tokens"] / quota
|
|
report["percentUsed"] = round(pct, 1)
|
|
if pct < BAND_CLEAR:
|
|
report["verdict"] = "clear"
|
|
report["verdictReason"] = "Dispatch freely. The window refills; unused capacity is wasted."
|
|
elif pct < BAND_WRAP:
|
|
report["verdict"] = "wrap-up"
|
|
report["verdictReason"] = (
|
|
"Keep working, but prefer shorter tasks over long fan-outs so nothing "
|
|
"large is in flight if the window tightens."
|
|
)
|
|
else:
|
|
report["verdict"] = "park"
|
|
report["verdictReason"] = (
|
|
"Finish what is running, commit, push, write the next step down, and park."
|
|
)
|
|
return report
|
|
|
|
|
|
def human(report: dict) -> str:
|
|
if "error" in report:
|
|
return f"usage: unavailable ({report['error']})"
|
|
|
|
lines = [
|
|
f"rolling {report['windowHours']}h window — all local Claude Code sessions",
|
|
]
|
|
|
|
quota = report.get("quota")
|
|
pct = report.get("percentUsed")
|
|
if quota and pct is not None:
|
|
lines.append(
|
|
f" usage {report['tokens']:,} / {quota:,} tokens = {pct:.1f}%"
|
|
f" [{report['quotaSource']}]"
|
|
)
|
|
else:
|
|
lines.append(f" tokens {report['tokens']:,} (no calibrated quota — percentage UNKNOWN)")
|
|
|
|
lines.append(
|
|
f" detail {report['outputTokens']:,} output over {report['messages']:,} messages"
|
|
)
|
|
|
|
if report["oldestAgesOutInSeconds"] is not None:
|
|
secs = report["oldestAgesOutInSeconds"]
|
|
lines.append(f" oldest entry ages out in {secs // 60}m{secs % 60:02d}s")
|
|
|
|
top = list(report["projects"].items())[:3]
|
|
if top:
|
|
lines.append(" busiest: " + ", ".join(f"{k} {v:,}" for k, v in top))
|
|
lines.append(f" sessions: {len(report['projects'])} project(s) contributing")
|
|
|
|
lines.append(f" VERDICT: {report['verdict']} — {report['verdictReason']}")
|
|
lines.append(
|
|
" FLOOR, not a ceiling: misses other machines, claude.ai and direct API use"
|
|
)
|
|
if not quota:
|
|
lines.append(
|
|
" calibrate: ask the user their /status percentage, then "
|
|
"usage-window.py --calibrate <pct>"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--hours", type=float, default=5.0)
|
|
parser.add_argument("--json", action="store_true")
|
|
parser.add_argument(
|
|
"--quota",
|
|
type=float,
|
|
default=None,
|
|
help="Window token quota for this run only (accepts 16e6). Overrides stored calibration.",
|
|
)
|
|
parser.add_argument(
|
|
"--calibrate",
|
|
type=float,
|
|
default=None,
|
|
metavar="PCT",
|
|
help="Percent-used that Claude Code currently reports. Stores the implied quota.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
report = collect(args.hours)
|
|
|
|
if args.calibrate is not None:
|
|
if "error" in report:
|
|
print(human(report))
|
|
raise SystemExit(1)
|
|
if not 0 < args.calibrate <= 100:
|
|
print("calibrate: percentage must be in (0, 100]")
|
|
raise SystemExit(2)
|
|
implied = int(report["tokens"] / (args.calibrate / 100.0))
|
|
save_quota(implied, report["tokens"], args.calibrate)
|
|
print(
|
|
f"calibrated: {report['tokens']:,} tokens reported as {args.calibrate}% "
|
|
f"=> window quota ~{implied:,}\n"
|
|
f"written to {QUOTA_FILE}\n"
|
|
"Note this inherits the script's blind spots (other machines, web, direct API),\n"
|
|
"so the true quota is this or LARGER. Re-calibrate after a plan change."
|
|
)
|
|
return
|
|
|
|
if args.quota is not None:
|
|
quota, source = int(args.quota), "--quota flag"
|
|
else:
|
|
quota, source = load_quota()
|
|
|
|
report = add_verdict(report, quota, source)
|
|
|
|
if args.json:
|
|
print(json.dumps(report, indent=2))
|
|
else:
|
|
print(human(report))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|