diff --git a/README.md b/README.md index 9d65eab..c3acf56 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ swap half-done. Here nothing moves: an account is a directory, and switching means pointing `CLAUDE_CONFIG_DIR` at another one. There is nothing to restore, and the plugins of an account come with it. +Lending a login is still useful when one account runs out of limit and its +profile is the one with everything set up, so `borrow` brings it back, built +against both ways the old helper failed -- see [Borrowing a login](#borrowing-a-login). + ## How the switch reaches `claude` `~/.local/bin/claude` is a small wrapper that asks this tool which profile is @@ -87,6 +91,9 @@ workspaces over unchanged. | `switch ` | make an account the one `claude` runs as | | `add ` | register an account: profile, port, workspace, proxy, all at once | | `remove ` | forget one; its profile directory stays | +| `stats [--json]` | what every proxy recorded since it was set up, side by side | +| `borrow [--into ] [--for 4h]` | run a profile on another account's login for a while | +| `return [account]` | give a borrowed login back | | `completions [shell]` | print a completion script, or `--install` it | | `setup` | wrapper, proxies, router and completions in one go | | `adopt` | take over services that were written by hand | @@ -113,6 +120,65 @@ directory and the proxy workspace stay where they are -- they hold a login, projects and memories -- so a profile that still has credentials keeps showing up in `list`, found rather than configured. +## What the proxies recorded + +```sh +claude-account stats +``` + +Every account's proxy, and every jail's, keeps its figures in its workspace. +`stats` reads them all and puts them side by side: calls, input, how much the +proxy saved and how much of that is compression rather than tool definitions +held back, the cache-read share, and how many calls ran above 150k tokens of +context -- the size at which a `/clear` pays more than any proxy. Below the +table it says what the totals mean, and lists each account's weekly limit with +the pace of the last three hours and when that pace would use it up. `--json` +prints the same figures for scripts. + +The figures are the proxy's own, counted since its workspace was created; the +counters on its `/stats` page start again with every restart. + +## Borrowing a login + +```sh +claude-account borrow personal --into work --for 4h +claude # a new session, on the personal login +claude-account return work # or wait for the four hours +``` + +The profile stays -- its plugins, settings, projects and memories -- and only +the login and the `oauthAccount` entry of `.claude.json` are lent. `--into` +defaults to the active account; without `--for` the borrow lasts until +`return`. Sessions that are already open keep the login they started with, so +the borrow is felt from the next session on. + +What makes it safe where the old helper was not: + +* **A journal before anything moves.** The profile's own credentials and + `oauthAccount` are written to `~/.local/share/claude-account/borrows/` + (mode 0600) first. `return` restores from it, whether it is run by hand, by + the timer `--for` sets, or after a crash or an interrupted borrow. +* **One login, not two copies.** The profile's `.credentials.json` becomes a + link to the lender's, so a token refreshed in either profile is the one both + use. If Claude Code replaces the link with a file, `return` gives whichever + copy expires later back to the lender. +* **Jails are refused.** A jail keeps customer data in a sandbox, and the + login decides whose account that data goes out under. Borrowing into a + profile a jail runs in needs `--with-jails`. +* **A lost timer is reported.** `--for` sets a transient user timer, which a + reboot drops; `status` and `doctor` then show the borrow as overdue with the + command that returns it. + +The profile's proxy polls the lender's limits while the borrow lasts, and +counts the lender's calls in its figures. The `enqs-info` status line shows +`⇄ ` next to the account name for as long as it does. + +A borrow is for one person's own accounts. A login shared between people is +against Anthropic's terms, however it is passed around -- a Team plan with a +seat each is the way to pool limits. And a private account is covered by the +consumer terms, not the company's agreement: work and customer data belong in +the work login. + ## Completions ```sh diff --git a/src/borrow.rs b/src/borrow.rs new file mode 100644 index 0000000..a9c48ce --- /dev/null +++ b/src/borrow.rs @@ -0,0 +1,335 @@ +//! Lend one account's login to another account's profile for a while. +//! +//! The rest of this tool never moves a credential: an account is a directory, +//! and switching points `CLAUDE_CONFIG_DIR` at another one. That keeps the +//! plugins, settings and memories of an account together with its login, +//! which is right until the login runs out of limit in the middle of a day and +//! the profile it belongs to is the one with everything set up. Switching then +//! means working in the other profile, with its plugins, its settings and its +//! history, or not at all. +//! +//! A borrow is the other way round: the profile stays, and the login of another +//! account is lent into it until it is returned. That is what the helper +//! before this tool did, and it went wrong in two ways that this module is +//! built against: +//! +//! * An abort left the swap half done. Here the profile's own login is +//! written to a journal before anything in the profile is touched, and +//! `return` restores from that journal whenever it runs -- by hand, from +//! the timer `--for` sets, or after a crash. +//! * A token refreshed in one copy invalidated the other. Here the lent +//! credentials file is a link to the lender's, so a refresh in either +//! profile is seen by both. Should Claude Code replace the link with a +//! file of its own, `return` gives whichever copy expires later back to +//! the lender. +//! +//! The profile's `.claude.json` names the account that is logged in, and only +//! that one key, `oauthAccount`, is lent with the credentials. The file holds +//! the profile's projects and settings besides, and stays the profile's. + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::os::unix::fs::{symlink, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use crate::account::{self, Account}; +use crate::config::Config; +use crate::paths::{data_dir, shorten}; +use crate::time; + +/// What a borrow changed, written before it changes it. +#[derive(Debug, Serialize, Deserialize)] +pub struct Journal { + /// The account whose profile holds the lent login. + pub target: String, + pub target_dir: PathBuf, + /// The account the login belongs to. + pub lender: String, + pub lender_dir: PathBuf, + pub since: i64, + /// When the timer set by `--for` returns it, if one was set. + pub until: Option, + /// The profile's own credentials file, as it was. `None` when there was + /// none, and then there is nothing to put back. + pub credentials: Option, + /// The profile's own `oauthAccount`, as it was. + pub oauth_account: Option, +} + +/// The file in the borrowing profile that says it is borrowing, for the +/// status line of the `enqs-info` plugin. It holds no credential. +pub const MARKER: &str = "claude-account-borrow.json"; + +const CREDENTIALS: &str = ".credentials.json"; +const PROFILE: &str = ".claude.json"; + +fn journal_path(target: &str) -> PathBuf { + data_dir().join("borrows").join(format!("{target}.json")) +} + +fn timer_unit(target: &str) -> String { + format!("claude-account-return-{target}") +} + +/// The borrow a profile is in, if it is in one. +pub fn of(target: &str) -> Option { + serde_json::from_str(&std::fs::read_to_string(journal_path(target)).ok()?).ok() +} + +/// Every borrow that has not been returned. +pub fn all() -> Vec { + let Ok(entries) = std::fs::read_dir(data_dir().join("borrows")) else { return Vec::new() }; + let mut out: Vec = entries + .flatten() + .filter_map(|entry| std::fs::read_to_string(entry.path()).ok()) + .filter_map(|body| serde_json::from_str(&body).ok()) + .collect(); + out.sort_by(|left: &Journal, right| left.target.cmp(&right.target)); + out +} + +/// What the caller should tell the reader after a borrow. +pub struct Lent { + pub journal: Journal, + /// Sessions in the borrowing profile that started before the borrow. + pub stale_sessions: usize, +} + +/// Lend `lender`'s login to `target`'s profile, for `seconds` if given. +pub fn lend(config: &Config, lender: &str, target: &str, seconds: Option) -> Result { + let lender = account::find(config, lender)?; + let target = account::find(config, target)?; + if lender.name == target.name { + return Err(anyhow!("{} cannot borrow its own login", target.name)); + } + if let Some(journal) = of(&target.name) { + return Err(anyhow!( + "{} already runs on the login of {}. Return it first: `claude-account return {}`.", + target.name, + journal.lender, + target.name + )); + } + // A lender that is itself borrowing would lend what is not its own, and + // returning the two in the wrong order would leave the wrong login behind. + if let Some(journal) = of(&lender.name) { + return Err(anyhow!( + "{} runs on the login of {} itself. Return that first: `claude-account return {}`.", + lender.name, + journal.lender, + lender.name + )); + } + let lender_credentials = lender.dir.join(CREDENTIALS); + if !lender_credentials.is_file() { + return Err(anyhow!("{} holds no login to lend", lender.name)); + } + if std::fs::symlink_metadata(target.dir.join(CREDENTIALS)).is_ok_and(|meta| meta.is_symlink()) { + return Err(anyhow!( + "the credentials of {} are a link already, and no borrow is recorded for it. \ + Look at {} before borrowing into it.", + target.name, + shorten(&target.dir.join(CREDENTIALS)) + )); + } + + let now = time::now(); + let journal = Journal { + target: target.name.clone(), + target_dir: target.dir.clone(), + lender: lender.name.clone(), + lender_dir: lender.dir.clone(), + since: now, + until: seconds.map(|seconds| now + seconds), + credentials: read_json(&target.dir.join(CREDENTIALS)), + oauth_account: read_json(&target.dir.join(PROFILE)) + .and_then(|profile| profile.get("oauthAccount").cloned()), + }; + if target.dir.join(CREDENTIALS).is_file() && journal.credentials.is_none() { + return Err(anyhow!( + "cannot read {}, so it could not be put back", + shorten(&target.dir.join(CREDENTIALS)) + )); + } + // The journal first: from here on, `return` can undo whatever follows. + write_private(&journal_path(&target.name), &serde_json::to_string_pretty(&journal)?)?; + + let result = swap_in(&lender, &target, &journal); + if let Err(error) = result { + restore(&journal) + .context("the borrow failed half way and could not be undone; run `claude-account return`")?; + std::fs::remove_file(journal_path(&target.name)) + .context("cannot remove the journal of the borrow")?; + return Err(error); + } + if let Some(seconds) = seconds { + schedule_return(&target.name, seconds)?; + } + let stale_sessions = account::running_sessions() + .into_iter() + .filter(|(_, profile)| profile.as_deref() == Some(target.dir.as_path())) + .count(); + Ok(Lent { journal, stale_sessions }) +} + +fn swap_in(lender: &Account, target: &Account, journal: &Journal) -> Result<()> { + let credentials = target.dir.join(CREDENTIALS); + if credentials.exists() { + std::fs::remove_file(&credentials) + .with_context(|| format!("cannot remove {}", shorten(&credentials)))?; + } + symlink(lender.dir.join(CREDENTIALS), &credentials) + .with_context(|| format!("cannot link {}", shorten(&credentials)))?; + let lent = read_json(&lender.dir.join(PROFILE)).and_then(|profile| profile.get("oauthAccount").cloned()); + set_oauth_account(&target.dir, lent)?; + let marker = serde_json::json!({ + "lender": journal.lender, + "since": journal.since, + "until": journal.until, + }); + std::fs::write(target.dir.join(MARKER), serde_json::to_string_pretty(&marker)? + "\n") + .with_context(|| format!("cannot write {}", shorten(&target.dir.join(MARKER))))?; + Ok(()) +} + +/// Give a lent login back and put the profile's own in its place. +pub fn give_back(target: &str) -> Result { + let journal = of(target).ok_or_else(|| anyhow!("{target} is not borrowing anything"))?; + restore(&journal)?; + // The timer that would have done this is no longer needed. It is gone + // already when it is the one running this, which is not an error. + let _ = Command::new("systemctl") + .args(["--user", "stop", &format!("{}.timer", timer_unit(target))]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + std::fs::remove_file(journal_path(target)).context("cannot remove the journal of the borrow")?; + Ok(journal) +} + +fn restore(journal: &Journal) -> Result<()> { + let credentials = journal.target_dir.join(CREDENTIALS); + let lender_credentials = journal.lender_dir.join(CREDENTIALS); + match std::fs::symlink_metadata(&credentials) { + Ok(meta) if meta.is_symlink() => {} + Ok(_) => { + // Claude Code wrote a file of its own over the link, with the + // lender's login in it and possibly a newer token than the one the + // lender holds. Whichever runs longer goes back to the lender, since + // a refresh may have made the other one useless. + let here = read_json(&credentials); + let there = read_json(&lender_credentials); + if here.as_ref().and_then(expires_at) > there.as_ref().and_then(expires_at) { + if let Some(here) = here { + write_private(&lender_credentials, &serde_json::to_string(&here)?)?; + } + } + } + Err(_) => {} + } + if std::fs::symlink_metadata(&credentials).is_ok() { + std::fs::remove_file(&credentials) + .with_context(|| format!("cannot remove {}", shorten(&credentials)))?; + } + if let Some(own) = &journal.credentials { + write_private(&credentials, &serde_json::to_string(own)?)?; + } + set_oauth_account(&journal.target_dir, journal.oauth_account.clone())?; + let marker = journal.target_dir.join(MARKER); + if marker.exists() { + std::fs::remove_file(&marker).with_context(|| format!("cannot remove {}", shorten(&marker)))?; + } + Ok(()) +} + +/// Set or remove `oauthAccount` in a profile's `.claude.json`, leaving every +/// other key as it is and in its order. +fn set_oauth_account(dir: &Path, value: Option) -> Result<()> { + let path = dir.join(PROFILE); + let mut profile = match std::fs::read_to_string(&path) { + Ok(body) => { + serde_json::from_str::(&body).with_context(|| format!("cannot read {}", shorten(&path)))? + } + Err(_) if value.is_none() => return Ok(()), + Err(_) => serde_json::json!({}), + }; + let object = profile.as_object_mut().ok_or_else(|| anyhow!("{} is not a JSON object", shorten(&path)))?; + match value { + Some(value) => { + object.insert("oauthAccount".to_string(), value); + } + None => { + object.remove("oauthAccount"); + } + } + write_private(&path, &serde_json::to_string_pretty(&profile)?) +} + +/// Return the borrow after `seconds`, by a transient timer of the user's +/// systemd. It survives this process and the terminal, and not a reboot -- +/// which `status` reports as a borrow past its time. +fn schedule_return(target: &str, seconds: i64) -> Result<()> { + let program = std::env::current_exe().context("cannot find this program to schedule the return")?; + let status = Command::new("systemd-run") + .args(["--user", "--quiet", "--collect"]) + .arg(format!("--unit={}", timer_unit(target))) + .arg(format!("--on-active={seconds}s")) + .args(located_by()) + .arg(&program) + .args(["return", target]) + .stdout(Stdio::null()) + .status() + .context("cannot run systemd-run")?; + if !status.success() { + return Err(anyhow!( + "the login is lent, but no timer could be set to return it; run `claude-account return {target}` yourself" + )); + } + Ok(()) +} + +/// The variables that decide where accounts and journals are found. A unit +/// started by the user manager inherits its environment, not this shell's, +/// so a return scheduled from a shell that moved them would look elsewhere. +fn located_by() -> Vec { + ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME"] + .iter() + .filter_map(|name| std::env::var(name).ok().map(|value| format!("--setenv={name}={value}"))) + .collect() +} + +/// Write a file only its owner may read, in one step: the credentials and +/// the journal holding a copy of them must never be readable by anyone else, +/// not even for the moment between writing and changing the mode. +fn write_private(path: &Path, body: &str) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("cannot create {}", shorten(parent)))?; + } + let temporary = path.with_extension(format!("tmp.{}", std::process::id())); + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temporary) + .with_context(|| format!("cannot write {}", shorten(&temporary)))?; + file.write_all(body.as_bytes())?; + if !body.ends_with('\n') { + file.write_all(b"\n")?; + } + } + std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o600))?; + std::fs::rename(&temporary, path).with_context(|| format!("cannot write {}", shorten(path))) +} + +fn expires_at(credentials: &Value) -> Option { + credentials.get("claudeAiOauth")?.get("expiresAt")?.as_i64() +} + +fn read_json(path: &Path) -> Option { + serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok() +} diff --git a/src/completions.rs b/src/completions.rs index 2fc8dd8..2ca22c8 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -18,7 +18,8 @@ use crate::Cli; /// The subcommands whose argument is an account. The dynamic rules offer names /// after each of them; anywhere else the generated script has the last word. -const TAKES_ACCOUNT: &str = "switch dir env url remove start stop restart logs install uninstall port"; +const TAKES_ACCOUNT: &str = + "switch borrow return dir env url remove start stop restart logs install uninstall port"; /// The completion script for one shell. pub fn script(shell: Shell) -> String { diff --git a/src/main.rs b/src/main.rs index f694db5..0722ffe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ //! whichever account was switched to last. mod account; +mod borrow; mod completions; mod config; mod headroom; @@ -11,8 +12,10 @@ mod paths; mod router; mod service; mod shim; +mod stats; mod status; mod systemd; +mod time; mod ui; use anyhow::{anyhow, Result}; @@ -73,6 +76,31 @@ enum Command { /// The account, as `list` names it account: String, }, + /// Lend another account's login to a profile, plugins and settings staying + Borrow { + /// The account whose login is lent + lender: String, + /// The account whose profile runs on it; the active one by default + #[arg(long)] + into: Option, + /// Return it by itself after this long: 90m, 3h, 1h30m, 2d + #[arg(long = "for", value_name = "DURATION")] + duration: Option, + /// Borrow even though jails run in that profile + #[arg(long)] + with_jails: bool, + }, + /// Give a borrowed login back and put the profile's own in its place + Return { + /// The account that borrowed; the only borrowing one by default + account: Option, + }, + /// What every proxy has recorded, side by side: saving, call sizes, limits + Stats { + /// Print the figures as JSON + #[arg(long)] + json: bool, + }, /// Print the name of the active account Current, /// Print the profile directory of an account, for `CLAUDE_CONFIG_DIR` @@ -220,6 +248,14 @@ fn run() -> Result<()> { add(&mut config, &name, dir, port, and_switch) } Command::Remove { account } => remove(&mut config, &account), + Command::Borrow { lender, into, duration, with_jails } => { + borrow(&config, &lender, into.as_deref(), duration.as_deref(), with_jails) + } + Command::Return { account } => give_back(&config, account.as_deref()), + Command::Stats { json } => { + stats::report(&config, json); + Ok(()) + } Command::Current => { let account = require_active(&config)?; println!("{}", account.name); @@ -314,6 +350,100 @@ fn add( Ok(()) } +/// Lend one account's login to another account's profile. +/// +/// The jails are the one reason to refuse. A jail keeps customer data inside +/// a sandbox, and the login decides whose account that data then goes out +/// under; a private login lent to the work profile would take it along +/// without a word. So a profile a jail runs in is only borrowed into when +/// that is said out loud. +fn borrow( + config: &Config, + lender: &str, + into: Option<&str>, + duration: Option<&str>, + with_jails: bool, +) -> Result<()> { + let target = one(config, into)?; + let seconds = duration.map(time::parse_span).transpose()?; + let jails = stats::jails_in(&target.dir); + if !jails.is_empty() && !with_jails { + return Err(anyhow!( + "the jails {} run in the profile of {}, and would send what they read through the \ + login of {lender}. Pass --with-jails to borrow anyway, or borrow into another profile.", + jails.join(", "), + target.name + )); + } + let lent = borrow::lend(config, lender, &target.name, seconds)?; + let journal = &lent.journal; + let lender = account::find(config, &journal.lender)?; + ui::done(format!( + "{} runs on the login of {} now, with its own plugins and settings.", + ui::bold(&journal.target), + ui::bold(&journal.lender) + )); + if let Some(email) = lender.email() { + ui::field("login", email); + } + match journal.until { + Some(until) => ui::field("returns", format!("by itself in {}", time::span(until - journal.since))), + None => ui::hint(format!("claude-account return {}", journal.target)), + } + if !jails.is_empty() { + ui::field("jails", ui::yellow(format!("{} run on the lent login too", jails.join(", ")))); + } + ui::field( + "proxy", + format!("{}'s proxy polls the limits of {} meanwhile", journal.target, journal.lender), + ); + if lent.stale_sessions > 0 { + ui::field( + "note", + ui::yellow(format!( + "{} session(s) of {} are open and keep the old login until restarted", + lent.stale_sessions, journal.target + )), + ); + } + Ok(()) +} + +fn give_back(config: &Config, name: Option<&str>) -> Result<()> { + let target = match name { + Some(name) => account::find(config, name)?.name, + None => { + let borrows = borrow::all(); + match borrows.as_slice() { + [] => return Err(anyhow!("no profile is borrowing a login")), + [only] => only.target.clone(), + _ => { + let names: Vec<&str> = borrows.iter().map(|journal| journal.target.as_str()).collect(); + return Err(anyhow!("{} are borrowing; name one", names.join(" and "))); + } + } + } + }; + let journal = borrow::give_back(&target)?; + ui::done(format!( + "{} runs on its own login again; {} has its back.", + ui::bold(&journal.target), + ui::bold(&journal.lender) + )); + ui::field("borrowed for", time::span(time::now() - journal.since)); + let open = account::running_sessions() + .into_iter() + .filter(|(_, profile)| profile.as_deref() == Some(journal.target_dir.as_path())) + .count(); + if open > 0 { + ui::field( + "note", + format!("{open} session(s) of {} still hold the lent login until restarted", journal.target), + ); + } + Ok(()) +} + /// Forget an account. The profile directory and the workspace stay: this /// command undoes a registration, not a login and not a year of projects. fn remove(config: &mut Config, name: &str) -> Result<()> { diff --git a/src/stats.rs b/src/stats.rs new file mode 100644 index 0000000..9de0866 --- /dev/null +++ b/src/stats.rs @@ -0,0 +1,405 @@ +//! What the proxies have recorded about themselves, side by side. +//! +//! Every proxy keeps its figures in its own workspace, and its dashboard shows +//! one proxy at a time -- the ones of the accounts and the ones the jails +//! started. Measured together they said things none of them says alone: that +//! nine tenths of the saving are tool definitions held back rather than +//! compression, that one compressed call in eight was already past the point +//! where a session is better cleared, and how fast a weekly limit is going. +//! +//! Only the files a proxy keeps about itself are read: `proxy_savings.json`, +//! `savings_events.jsonl`, `subscription_state.json` and `toin.json`. The +//! retrieval store and the logs hold what the sessions sent, and in a jail that +//! is customer data. They are never opened, so this report carries numbers and +//! no content. + +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +use crate::account; +use crate::config::Config; +use crate::paths::{expand_user, home, shorten}; +use crate::time; +use crate::ui; + +/// Where the status line of the `enqs-info` plugin starts to suggest `/clear`, +/// in tokens. A compressed call above it is one that re-sends a context a +/// fresh session would not have. +const CLEAR_AT: u64 = 150_000; + +/// How far back the pace of a weekly limit is measured, in seconds. +const PACE_SPAN: i64 = 3 * 3600; + +/// The figures of one proxy. +#[derive(Default)] +struct Proxy { + name: String, + workspace: PathBuf, + requests: u64, + input: u64, + output: u64, + cache_reads: u64, + compression: u64, + tool_definitions: u64, + /// Sizes of the calls the proxy compressed, before it did. + sizes: Vec, + compressions: u64, + retrievals: u64, + week: Option, +} + +/// The weekly limit as the proxy last polled it. +struct Week { + used: f64, + resets_at: i64, + polled_at: i64, + /// Points per hour over the last polls, where they span long enough. + pace: Option, +} + +impl Proxy { + fn saved(&self) -> u64 { + self.compression + self.tool_definitions + } + + fn above_clear(&self) -> usize { + self.sizes.iter().filter(|&&size| size > CLEAR_AT).count() + } +} + +/// Print the report, or the same figures as JSON. +pub fn report(config: &Config, as_json: bool) { + let proxies: Vec = + workspaces(config).into_iter().filter_map(|(name, workspace)| read(name, workspace)).collect(); + if as_json { + println!("{}", serde_json::to_string_pretty(&to_json(&proxies)).unwrap_or_default()); + return; + } + if proxies.is_empty() { + println!("No proxy has recorded anything yet."); + return; + } + print(&proxies); +} + +/// Every workspace worth reading: one per account, then one per jail that +/// runs a proxy of its own. +fn workspaces(config: &Config) -> Vec<(String, PathBuf)> { + let mut out: Vec<(String, PathBuf)> = + account::all(config).into_iter().map(|account| (account.name, account.workspace)).collect(); + for (name, dir) in jails() { + let workspace = dir.join(".enqs-jail/headroom"); + if !out.iter().any(|(_, known)| known == &workspace) { + out.push((format!("jail {name}"), workspace)); + } + } + out +} + +/// The jails the `enqs-jail` plugin has registered, by name and directory. +pub fn jails() -> Vec<(String, PathBuf)> { + let Some(registry) = read_json(®istry_path()) else { return Vec::new() }; + let Some(jails) = registry.get("jails").and_then(Value::as_object) else { return Vec::new() }; + jails + .iter() + .filter_map(|(name, jail)| Some((name.clone(), expand_user(jail.get("path")?.as_str()?)))) + .collect() +} + +/// The jails whose sessions run in a given profile directory. +pub fn jails_in(profile: &Path) -> Vec { + let Some(registry) = read_json(®istry_path()) else { return Vec::new() }; + let Some(jails) = registry.get("jails").and_then(Value::as_object) else { return Vec::new() }; + jails + .iter() + .filter(|(_, jail)| { + jail.get("profile").and_then(Value::as_str).map(expand_user).as_deref() == Some(profile) + }) + .map(|(name, _)| name.clone()) + .collect() +} + +/// `~/.config/enqs/jails.json`, where `enqs-jail` keeps its registry. +fn registry_path() -> PathBuf { + if let Ok(path) = std::env::var("ENQS_JAIL_REGISTRY") { + if !path.is_empty() { + return expand_user(&path); + } + } + match std::env::var("XDG_CONFIG_HOME") { + Ok(value) if !value.is_empty() => PathBuf::from(value), + _ => home().join(".config"), + } + .join("enqs/jails.json") +} + +fn read(name: String, workspace: PathBuf) -> Option { + let savings = read_json(&workspace.join("proxy_savings.json"))?; + let lifetime = savings.get("lifetime")?; + let mut proxy = Proxy { + requests: number(lifetime, "requests"), + input: number(lifetime, "total_input_tokens"), + cache_reads: number(lifetime, "cache_read_tokens"), + // `tokens_saved` is compression alone. The tool definitions a proxy + // holds back until a tool is searched for are the larger share, and + // are only counted per model. + compression: number(lifetime, "tokens_saved"), + tool_definitions: savings + .get("by_model") + .and_then(Value::as_object) + .map(|models| models.values().map(|model| number(model, "tool_tokens_saved")).sum()) + .unwrap_or(0), + output: savings.pointer("/lifetime_metrics/tokens/output").and_then(Value::as_u64).unwrap_or(0), + name, + ..Proxy::default() + }; + + if let Ok(body) = std::fs::read_to_string(workspace.join("savings_events.jsonl")) { + proxy.sizes = body + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter_map(|event| event.get("before").and_then(Value::as_u64)) + .collect(); + proxy.sizes.sort_unstable(); + } + + if let Some(toin) = read_json(&workspace.join("toin.json")) { + if let Some(patterns) = toin.get("patterns").and_then(Value::as_object) { + proxy.compressions = patterns.values().map(|pattern| number(pattern, "total_compressions")).sum(); + proxy.retrievals = patterns.values().map(|pattern| number(pattern, "total_retrievals")).sum(); + } + } + + proxy.week = read_json(&workspace.join("subscription_state.json")).and_then(|state| week(&state)); + proxy.workspace = workspace; + Some(proxy) +} + +/// The weekly limit, and how fast it rose over the polls of the last hours. +/// +/// The limit comes in whole points, so polls less than half an hour apart are +/// too coarse to give a pace; the proxy polls every five minutes while it is +/// used, which is plenty after that. +fn week(state: &Value) -> Option { + let latest = state.get("latest")?.get("seven_day")?; + let polled_at = time::parse_iso(state.get("latest")?.get("polled_at")?.as_str()?)?; + let mut week = Week { + used: latest.get("utilization_pct")?.as_f64()?, + resets_at: time::parse_iso(latest.get("resets_at")?.as_str()?)?, + polled_at, + pace: None, + }; + let polls: Vec<(i64, f64)> = state + .get("history") + .and_then(Value::as_array) + .map(|history| { + history + .iter() + .filter_map(|poll| { + let at = time::parse_iso(poll.get("polled_at")?.as_str()?)?; + let used = poll.get("seven_day")?.get("utilization_pct")?.as_f64()?; + Some((at, used)) + }) + .filter(|(at, _)| polled_at - at <= PACE_SPAN) + .collect() + }) + .unwrap_or_default(); + if let Some(&(first_at, first_used)) = polls.iter().min_by_key(|(at, _)| *at) { + // A reset in between makes the rise negative; that is no pace. + if polled_at - first_at >= 1800 && week.used >= first_used { + week.pace = Some((week.used - first_used) * 3600.0 / (polled_at - first_at) as f64); + } + } + Some(week) +} + +impl Week { + /// Seconds from now until the limit is used up at this pace, when that is + /// before it resets. + fn full_in(&self, now: i64) -> Option { + let pace = self.pace.filter(|pace| *pace > 0.0)?; + let left = ((100.0 - self.used) / pace * 3600.0) as i64 - (now - self.polled_at); + (left < self.resets_at - now).then_some(left.max(0)) + } +} + +fn print(proxies: &[Proxy]) { + let now = time::now(); + ui::heading("Since each proxy was set up"); + println!( + " {}", + ui::dim(format!( + "{:<16} {:>6} {:>7} {:>7} {:>7} {:>7} {:>7}", + "", "calls", "input", "saved", "compr.", "cache", ">150k" + )) + ); + for proxy in proxies { + println!( + " {} {:>6} {:>7} {:>7} {:>7} {:>7} {:>7}", + ui::bold(format!("{:<16}", proxy.name)), + proxy.requests, + tokens(proxy.input), + percent(proxy.saved(), proxy.input, 1), + percent(proxy.compression, proxy.input, 2), + percent(proxy.cache_reads, proxy.input, 0), + percent(proxy.above_clear() as u64, proxy.sizes.len() as u64, 1), + ); + } + let total = combine(proxies); + println!( + " {} {:>6} {:>7} {:>7} {:>7} {:>7} {:>7}", + ui::bold(format!("{:<16}", "together")), + total.requests, + tokens(total.input), + percent(total.saved(), total.input, 1), + percent(total.compression, total.input, 2), + percent(total.cache_reads, total.input, 0), + percent(total.above_clear() as u64, total.sizes.len() as u64, 1), + ); + + ui::heading("What that means"); + ui::field( + "saving", + format!( + "{} of it is tool definitions held back, {} compression of the conversation", + percent(total.tool_definitions, total.saved(), 0), + percent(total.compression, total.saved(), 0) + ), + ); + if !total.sizes.is_empty() { + ui::field( + "call size", + format!( + "median {}, {} of compressed calls above {} — past the /clear mark", + tokens(total.sizes[total.sizes.len() / 2]), + percent(total.above_clear() as u64, total.sizes.len() as u64, 1), + tokens(CLEAR_AT) + ), + ); + } + ui::field( + "output", + format!( + "{} of input: the limits are spent on reading, not writing", + percent(total.output, total.input, 2) + ), + ); + if total.compressions > 0 { + ui::field( + "retrievals", + format!( + "{} of {} compressed tool outputs were fetched back in full", + total.retrievals, total.compressions + ), + ); + } + + let weeks: Vec<&Proxy> = proxies.iter().filter(|proxy| proxy.week.is_some()).collect(); + if weeks.is_empty() { + return; + } + ui::heading("Weekly limits, as each proxy last polled them"); + let width = weeks.iter().map(|proxy| proxy.name.len()).max().unwrap_or(0); + for proxy in weeks { + let Some(week) = &proxy.week else { continue }; + let name = format!("{: now { + format!("resets in {}", time::span(week.resets_at - now)) + } else { + "reset since".to_string() + }; + let mut line = format!("{:>3.0} % {resets}", week.used); + if let Some(pace) = week.pace { + line.push_str(&format!(", {pace:.1} points an hour")); + } + line.push_str(&ui::dim(format!(", polled {} ago", time::span(now - week.polled_at)))); + match week.full_in(now) { + Some(left) if week.resets_at > now => { + ui::warn(&name, format!("{line} — {}", ui::yellow(format!("full in {}", time::span(left))))) + } + _ => ui::ok(&name, line), + } + } +} + +/// The figures of every proxy added up. The weekly limit is left out: two +/// proxies of one account poll the same limit, and a sum of limits is none. +fn combine(proxies: &[Proxy]) -> Proxy { + let mut total = Proxy::default(); + for proxy in proxies { + total.requests += proxy.requests; + total.input += proxy.input; + total.output += proxy.output; + total.cache_reads += proxy.cache_reads; + total.compression += proxy.compression; + total.tool_definitions += proxy.tool_definitions; + total.sizes.extend(&proxy.sizes); + total.compressions += proxy.compressions; + total.retrievals += proxy.retrievals; + } + total.sizes.sort_unstable(); + total +} + +fn to_json(proxies: &[Proxy]) -> Value { + let now = time::now(); + let one = |proxy: &Proxy| { + let sizes = &proxy.sizes; + json!({ + "requests": proxy.requests, + "input": proxy.input, + "output": proxy.output, + "cache_reads": proxy.cache_reads, + "compression": proxy.compression, + "tool_definitions": proxy.tool_definitions, + "saved": proxy.saved(), + "compressed_calls": sizes.len(), + "compressed_calls_above_clear": proxy.above_clear(), + "compressed_call_median": sizes.get(sizes.len() / 2), + "compressed_call_max": sizes.last(), + "compressions": proxy.compressions, + "retrievals": proxy.retrievals, + }) + }; + let mut out = serde_json::Map::new(); + for proxy in proxies { + let mut value = one(proxy); + value["workspace"] = json!(shorten(&proxy.workspace)); + if let Some(week) = &proxy.week { + value["week"] = json!({ + "used_pct": week.used, + "resets_at": week.resets_at, + "polled_at": week.polled_at, + "points_per_hour": week.pace, + "full_in_seconds": week.full_in(now), + }); + } + out.insert(proxy.name.clone(), value); + } + json!({ "clear_at": CLEAR_AT, "together": one(&combine(proxies)), "proxies": out }) +} + +fn number(value: &Value, key: &str) -> u64 { + value.get(key).and_then(Value::as_u64).unwrap_or(0) +} + +fn percent(part: u64, whole: u64, digits: usize) -> String { + if whole == 0 { + return "–".to_string(); + } + format!("{:.digits$} %", 100.0 * part as f64 / whole as f64) +} + +/// Tokens as 950, 12k, 1.2M or 937M. +fn tokens(count: u64) -> String { + match count { + 0..=999 => count.to_string(), + 1_000..=999_999 => format!("{}k", count / 1000), + 1_000_000..=99_999_999 => format!("{:.1}M", count as f64 / 1e6), + _ => format!("{}M", count / 1_000_000), + } +} + +fn read_json(path: &Path) -> Option { + serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok() +} diff --git a/src/status.rs b/src/status.rs index 0bd58d2..617e17c 100644 --- a/src/status.rs +++ b/src/status.rs @@ -9,6 +9,7 @@ //! checks each of them and says the command that repairs it. use crate::account::{self, Account}; +use crate::borrow; use crate::config::Config; use crate::headroom; use crate::paths::shorten; @@ -16,6 +17,7 @@ use crate::router; use crate::service; use crate::shim; use crate::systemd; +use crate::time; use crate::ui; /// Something that is wrong, and the command that fixes it. @@ -70,6 +72,7 @@ fn account_section( ui::field("profile", shorten(&account.dir)); ui::field("proxy", proxy_line(config, account)); ui::field("plugins", plugins_line(account)); + borrow_line(account, problems); match account.token_minutes_left() { Some(minutes) if minutes < 0 => { ui::field("token", ui::red("expired")); @@ -122,6 +125,36 @@ fn account_section( let _ = config; } +/// Whose login a profile runs on, when it is not its own. A borrow past its +/// end means the timer that should have returned it is gone -- a reboot drops +/// transient units -- and the profile keeps spending the lender's limits. +fn borrow_line(account: &Account, problems: &mut Vec) { + let Some(journal) = borrow::of(&account.name) else { + return; + }; + let now = time::now(); + match journal.until { + Some(until) if until <= now => { + ui::field( + "borrowed", + ui::red(format!("from {}, overdue by {}", journal.lender, time::span(now - until))), + ); + problems.push(Problem::new( + format!( + "{} still runs on the login of {} past the end of the borrow", + account.name, journal.lender + ), + Some(&format!("claude-account return {}", account.name)), + )); + } + Some(until) => ui::field( + "borrowed", + ui::yellow(format!("from {}, returns in {}", journal.lender, time::span(until - now))), + ), + None => ui::field("borrowed", ui::yellow(format!("from {}, until returned", journal.lender))), + } +} + /// Where an account's proxy is reached. Every account has a name of its own, /// so a session of either one can show its dashboard while both are open. fn proxy_line(config: &Config, account: &Account) -> String { diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..d3c83b7 --- /dev/null +++ b/src/time.rs @@ -0,0 +1,113 @@ +//! Seconds, the timestamps Headroom writes, and durations as a reader types +//! and reads them. +//! +//! Written by hand rather than pulled in as a crate: the tool needs to read one +//! timestamp format, add and subtract seconds, and print "3h40m". Everything +//! is in UTC and relative to now, so no time zone is ever involved. + +use anyhow::{anyhow, Result}; + +pub fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs() as i64) + .unwrap_or(0) +} + +/// Seconds since the epoch of `2026-09-23T15:13:45Z`, with or without a +/// fraction, and with `Z` or `+00:00` -- the forms Headroom writes. Another +/// offset is not UTC and is refused rather than read wrong. +pub fn parse_iso(value: &str) -> Option { + let (date, time) = value.split_once('T')?; + let mut date = date.split('-').map(|part| part.parse::().ok()); + let (year, month, day) = (date.next()??, date.next()??, date.next()??); + let time = time.strip_suffix('Z').or_else(|| time.strip_suffix("+00:00"))?; + let time = time.split('.').next()?; + let mut time = time.split(':').map(|part| part.parse::().ok()); + let (hour, minute, second) = (time.next()??, time.next()??, time.next().flatten().unwrap_or(0)); + Some(days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second) +} + +/// Days since 1970-01-01 of a date in the proleptic Gregorian calendar. +fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { + let year = if month <= 2 { year - 1 } else { year }; + let era = year.div_euclid(400); + let year_of_era = year - era * 400; + let day_of_year = (153 * ((month + 9) % 12) + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era - 719_468 +} + +/// Seconds as the two largest units that say something: 2d4h, 3h40m, 12m. +pub fn span(seconds: i64) -> String { + let seconds = seconds.max(0); + let (days, hours, minutes) = (seconds / 86_400, seconds % 86_400 / 3600, seconds % 3600 / 60); + match (days, hours) { + (0, 0) => format!("{minutes}m"), + (0, _) if minutes == 0 => format!("{hours}h"), + (0, _) => format!("{hours}h{minutes:02}m"), + _ if hours == 0 => format!("{days}d"), + _ => format!("{days}d{hours}h"), + } +} + +/// A duration as it is typed: `90m`, `3h`, `1h30m`, `2d`, or plain seconds. +pub fn parse_span(value: &str) -> Result { + let invalid = || anyhow!("{value} is not a duration; write it as 90m, 3h, 1h30m or 2d"); + let mut total = 0; + let mut number = String::new(); + for character in value.trim().chars() { + if character.is_ascii_digit() { + number.push(character); + continue; + } + let amount: i64 = number.parse().map_err(|_| invalid())?; + number.clear(); + total += amount + * match character { + 'd' => 86_400, + 'h' => 3600, + 'm' => 60, + 's' => 1, + _ => return Err(invalid()), + }; + } + if !number.is_empty() { + total += number.parse::().map_err(|_| invalid())?; + } + if total <= 0 { + return Err(invalid()); + } + Ok(total) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn headroom_timestamps_are_read() { + assert_eq!(parse_iso("1970-01-01T00:00:00Z"), Some(0)); + assert_eq!(parse_iso("2026-09-23T15:13:45Z"), Some(1_790_176_425)); + assert_eq!(parse_iso("2026-09-23T15:13:45.123+00:00"), Some(1_790_176_425)); + assert_eq!(parse_iso("2026-09-23T15:13:45+02:00"), None); + } + + #[test] + fn a_span_names_two_units() { + assert_eq!(span(59), "0m"); + assert_eq!(span(3600), "1h"); + assert_eq!(span(3600 + 40 * 60), "1h40m"); + assert_eq!(span(2 * 86_400 + 4 * 3600 + 59), "2d4h"); + } + + #[test] + fn a_duration_is_read_as_typed() { + assert_eq!(parse_span("90m").unwrap(), 5400); + assert_eq!(parse_span("1h30m").unwrap(), 5400); + assert_eq!(parse_span("2d").unwrap(), 172_800); + assert_eq!(parse_span("45").unwrap(), 45); + assert!(parse_span("soon").is_err()); + assert!(parse_span("0h").is_err()); + } +}