// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® export module Crafter.Review:Seats; import std; import :Json; // How much usage each Claude seat has left: the dotenv loader, the one-word // probe, the window maths, the ranking and the report. The seat pool // (:SeatPool) places on these; `crafter-review seats` prints the report (its // glue, which needs the ledger and the board, is in :Driver). // // Seats are CLAUDE_CODE_OAUTH_TOKEN1..6 by fixed name (the legacy // CLAUDE_CODE_OAUTH_TOKEN is seat 1 when TOKEN1 is empty), API-credit seats // ANTHROPIC_API_KEY1..6, OpenRouter seats OPENROUTER_KEY[1..6]; never printed: // the table and the JSON carry indices and percentages only. A probe result // and a seat are JSON: the dump and --json show them. export namespace Crafter::Review::Seats { constexpr std::int32_t MaxSeats = 6; constexpr std::string_view ProbeModel = "haiku"; // The Fable weekly limit is a window of its own, reported ONLY in the // rate_limit_event of a Fable request (verified 2026-09-08). The CLI has // named it seven_day_overage_included and seven_day_fable; either lands // under the canonical key. constexpr std::string_view FableProbeModel = "claude-fable-5-1"; constexpr std::string_view FableWindow = "seven_day_overage_included"; constexpr double ProbeTimeoutS = 30; // Fixed rules, not knobs: utilisation is rounded to RankQuantum before // ranking, and a seat whose 7-day window is at or above LongWindowCutoff is // out regardless — its reset is days away. The Fable window is exempt here. // 99 since 2026-10-15 (95 before): the weekly window is the budget the // lenses run out of, and the pool projects its running passes against the // cutoff (SeatPool::LensCost7dPct), so the last percent is spent a pass at // a time, not in a burst. constexpr std::int32_t RankQuantum = 5; constexpr std::int32_t LongWindowCutoff = 99; // JavaScript's Math.round: halves go up, never to even. double JsRound(double x); // A number the way a JS template prints it: 5 not 5.0, 62.5 as is. std::string Fmt(const Json& value); // {maxUtil, lensCostPct, waitMaxMin, policy, handoverPct}: the placement // knobs; garbage leaves the default. Json DefaultConfig(); Json ConfigFromEnv(const std::map& env); std::vector> SeatTokensFromEnv(const std::map& env); std::vector> ApiKeysFromEnv(const std::map& env); std::vector> OpenRouterKeysFromEnv(const std::map& env); // Every `[export ]NAME=value` line of a dotenv text, in order: blanks // around the `=` and the value dropped, one pair of matching quotes // stripped; anything else skipped. std::vector> DotenvLines(std::string_view text); // Only the seat lines of those: the OAuth tokens, the numbered API keys // and the OpenRouter keys; everything else ignored. std::map SeatTokensFromEnvFile(std::string_view text); // The REVIEW_GATES_* lines of a dotenv file (quotes and `export` // stripped). SeatPool::SettingsOf lays the placement settings among them // (SeatPool::PlacementSettings) under the process environment in the map // the placement readers take, where no other reader sees them, and names // the rest. std::map SettingsFromEnvFile(std::string_view text); // The CLI's fraction as a percent, past 100 for a seat over its limit. Json Pct(const Json& utilization); // {status, rateLimitType, resetsAt, windows, overageReason}, or null. Json ParseRateLimitInfo(const Json& raw); // The last result line, the last rate_limit_event and the session id. Json ParseStream(std::string_view text); std::vector ProbeArgs(std::string_view model = ProbeModel); // The probe child's whole environment: one token under the name the CLI // reads, an isolated config dir, thinking off, no nonessential traffic, no // updater, and none of the other pool tokens. std::map ProbeEnv(std::string_view token, const std::string& configDir, const std::map& base); // The CLI's own login: no token or API key from the environment. std::map LocalEnv(const std::map& base); // The key's approval in the config dir's .claude.json, once. std::filesystem::path WriteApiKeyApproval(const std::filesystem::path& configDir, std::string_view key); std::map ApiEnv(std::string_view key, const std::string& configDir, const std::map& base); bool LooksLikeCreditExhaustion(const Json& result, std::string_view text); bool LooksLikeAuthFailure(const Json& result, std::string_view text); // One line, at most 160 characters, the seat's own secrets and anything // token-shaped redacted. std::string ClipReason(std::string_view text, std::span secrets = {}); struct ProbeOptions { std::string model = std::string(ProbeModel); double timeoutS = ProbeTimeoutS; std::string claudeBin; bool local = false; bool api = false; }; // One probe of one seat: {seat, probedWith, windows, state: ok | limited | // unknown | invalid | out, util, binding, resetsAt, error, costUsd}. Json ProbeSeat(std::int32_t seat, const std::string& token, const ProbeOptions& options); double Quantize(double util); // The most-used window and its utilisation, raw. std::pair BindingWindow(const Json& windows); // Quantised for ranking. std::pair SeatUtil(const Json& windows); // The window a burst of passes moves: the 5-hour one, else the weekly one. // Anthropic removes the 5-hour limit in stages (from 2026-10-31), and an // account without it reports no 5-hour window. nullopt for a seat with no // reading. std::optional BurstWindow(const Json& windows); // A window's utilisation as a report cell: `no limit` in the 5-hour column // of an account without that limit. std::string WindowCell(const Json& windows, std::string_view window); std::string ModelFamily(std::string_view model); bool IsFableWindow(std::string_view key); const Json* FableWindowOf(const Json& seat); bool LongWindowsOk(const Json& seat); bool IsEligible(const Json& seat, const Json& config); bool FableOpen(const Json& seat, const Json& config); Json PickNext(const std::vector& seats, const Json& config); bool NeedsHaiku(const Json& seat); std::string FableState(const Json& fable); Json MergeFableProbe(Json base, const Json& fable); // Where the CLI is: CLAUDE_BIN / REVIEW_GATES_CLAUDE_BIN, then PATH, then // the editor extensions' bundled binary (newest first), then the // standalone installs. The bare name when nothing is found. std::string FindClaudeBin(const std::map& env, const std::string& home); std::string FindClaudeBin(); // The report's zone: $TZ, else /etc/localtime. A zone by name, as // zoneinfo reads it (this libc++ has no tzdb, so the C library converts, // with TZ set to the name); an empty name is the process's own zone. struct Zone { std::string name; std::string label; }; Zone LocalZone(); Zone ZoneNamed(std::string_view name); // "02:40": the clock in the zone. std::string Clock(double epoch, const Zone& zone); // "02:40 (in 1h 50m)"; more than a day out with its date: "Wed 16 Sept, // 11:00 (in 6d 10h)". std::string FormatWhen(double epoch, double now, const Zone& zone); std::string RenderTable(const std::vector>& rows); std::string RenderStatusReport(const std::vector& seats, const Json& config, double now, const Zone& zone, const Json& board = nullptr, const std::map& keys = {}); std::string RenderApiReport(const std::vector& apiSeats, const Json& apiConfig, const Json& board = nullptr, const std::map& keys = {}); std::string RenderDrivers(const Json& board, double now, const Zone& zone); }