diff --git a/Cargo.lock b/Cargo.lock index 6b504b8..b168d89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -867,6 +867,17 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "lumbridge-harness" +version = "0.0.1" +dependencies = [ + "lumbridge-core", + "nix", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "lumbridge-pty" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 10f2c6c..6e186d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "apps/lumbridge", "crates/lumbridge-buzz", "crates/lumbridge-core", + "crates/lumbridge-harness", "crates/lumbridge-pty", "crates/lumbridge-runtime", "crates/lumbridge-storage", diff --git a/README.md b/README.md index d58ffa1..1cd9019 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,12 @@ responsive one-, three-, or five-panel workspace—five on a 3440 px ultrawide where every panel owns its own context, work surface, and decision shelf. One panel contains a styled actor-owned VT session and five comparison surfaces remain deterministic. -Retained-history navigation is wired. We are still validating terminal text -selection and mouse input, standalone runtime IPC/durability, ACP integration, -packaging, and usage-data contracts before a large implementation. +Retained-history navigation is wired. The usage footer is a live strip over an +append-only ledger with provenance, fed by two real adapters: Codex's +documented quota surface and Claude Code's session transcripts. A harness with +no adapter renders an explicit gap rather than a zero. We are still validating +terminal text selection and mouse input, standalone runtime IPC/durability, ACP +integration, and packaging before a large implementation. ## Product shape diff --git a/crates/lumbridge-core/src/lib.rs b/crates/lumbridge-core/src/lib.rs index a9c0c66..b435ec8 100644 --- a/crates/lumbridge-core/src/lib.rs +++ b/crates/lumbridge-core/src/lib.rs @@ -2,8 +2,13 @@ use std::fmt; +mod usage; mod workspace; +pub use usage::{ + AccountProfile, AccountProfileId, FooterUsage, UsageConfidence, UsageError, UsageLedger, + UsageObservation, UsageProjection, UsageProvenance, UsageUnit, UsageWindow, format_duration_ms, +}; pub use workspace::{ CommandOrigin, CommandRequestId, LayoutNode, PaneCloseDisposition, PaneDefinition, PaneId, PaneLaunchIntent, PanePlacement, PaneSurface, SplitAxis, SplitRatio, WorkspaceApplyOutcome, @@ -96,15 +101,6 @@ impl fmt::Display for Platform { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum UsageProvenance { - ProviderReported, - HarnessReported, - LocallyMeasured, - Estimated, - Unavailable, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct ProductStatus { pub platform: Platform, diff --git a/crates/lumbridge-core/src/usage.rs b/crates/lumbridge-core/src/usage.rs new file mode 100644 index 0000000..9b5bcad --- /dev/null +++ b/crates/lumbridge-core/src/usage.rs @@ -0,0 +1,1371 @@ +//! Append-only usage observations and the derived footer projection. +//! +//! Decision 0002 requires every usage value to carry its provenance, and the +//! product footer must answer five questions without inventing precision: +//! which profile is active, how much of the window is consumed, when the window +//! resets, what the burn rate is, and how trustworthy those numbers are. +//! +//! This module stores facts, derives views from them, and reports what it +//! cannot know. Nothing here reads a clock: callers supply timestamps so the +//! projection is deterministic and testable. All arithmetic is integer +//! arithmetic; fractions are carried as permille so a rendered percentage never +//! drifts from the stored fact. +//! +//! ``` +//! use lumbridge_core::{ +//! AccountProfile, FooterUsage, UsageLedger, UsageObservation, UsageProvenance, UsageUnit, +//! UsageWindow, +//! }; +//! +//! let profile = AccountProfile::new("codex-plus", "Codex", "ChatGPT", "gpt-5.2", "work")?; +//! let window = UsageWindow::new(0, 18_000_000)?; +//! let mut ledger = UsageLedger::new(); +//! +//! for (at_ms, consumed) in [(0, 0), (3_600_000, 620_000)] { +//! ledger.record( +//! UsageObservation::counted( +//! profile.id().clone(), +//! UsageUnit::Tokens, +//! consumed, +//! UsageProvenance::ProviderReported, +//! at_ms, +//! )? +//! .with_limit(1_000_000)? +//! .with_window(window)?, +//! )?; +//! } +//! +//! let projection = ledger.project(profile.id(), 3_600_000); +//! assert_eq!(projection.consumed_permille(), Some(620)); +//! assert_eq!(projection.burn_per_hour(), Some(620_000)); +//! let footer = FooterUsage::new(&profile, &projection); +//! assert_eq!(footer.window(), "62.0% used"); +//! # Ok::<(), lumbridge_core::UsageError>(()) +//! ``` + +use std::collections::{BTreeMap, VecDeque}; +use std::fmt; + +use thiserror::Error; + +const MAX_IDENTITY_BYTES: usize = 256; +const DEFAULT_RETENTION_PER_PROFILE: usize = 64; +const MIN_RETENTION_PER_PROFILE: usize = 2; +const DEFAULT_STALENESS_BUDGET_MS: u64 = 300_000; +const MIN_BURN_SAMPLE_MS: u64 = 1_000; +const MILLIS_PER_HOUR: u128 = 3_600_000; +const MILLIS_PER_MINUTE: u64 = 60_000; +const MILLIS_PER_SECOND: u64 = 1_000; +const MINUTES_PER_HOUR: u64 = 60; +const HOURS_PER_DAY: u64 = 24; +const PERMILLE_SCALE: u128 = 1_000; +const MAX_PERMILLE: u64 = 1_000; + +/// Failures produced while recording or shaping usage facts. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum UsageError { + #[error("invalid {0}")] + InvalidIdentifier(&'static str), + #[error("a usage window must reset after it starts")] + EmptyWindow, + #[error("a usage limit must be greater than zero")] + ZeroLimit, + #[error("observation at {observed_at_ms} ms precedes the newest observation at {newest_ms} ms")] + OutOfOrderObservation { observed_at_ms: u64, newest_ms: u64 }, + #[error("observation at {observed_at_ms} ms falls outside its declared window")] + ObservationOutsideWindow { observed_at_ms: u64 }, + #[error("retention must keep at least two observations per profile")] + RetentionTooSmall, + #[error("an unavailable observation cannot carry a counted value")] + UnavailableWithValue, +} + +/// Where a usage number came from. +/// +/// The ordering is deliberate: variants are declared from most to least +/// authoritative so [`UsageProvenance::weakest`] can take the maximum. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum UsageProvenance { + /// The provider's own account or quota API reported this value. + ProviderReported, + /// The upstream harness reported it through a documented surface. + HarnessReported, + /// Lumbridge counted it locally from traffic it owns. + LocallyMeasured, + /// Lumbridge derived it from other observations. + Estimated, + /// No usable value exists. This is a fact, not a zero. + Unavailable, +} + +impl UsageProvenance { + /// The label kept in detail views and exports. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::ProviderReported => "provider-reported", + Self::HarnessReported => "harness-reported", + Self::LocallyMeasured => "locally measured", + Self::Estimated => "estimated", + Self::Unavailable => "unavailable", + } + } + + /// The short label a compact surface such as the footer may substitute. + #[must_use] + pub const fn compact_label(self) -> &'static str { + match self { + Self::ProviderReported => "provider", + Self::HarnessReported => "harness", + Self::LocallyMeasured => "local", + Self::Estimated => "est", + Self::Unavailable => "none", + } + } + + /// Whether an external party stated this value rather than Lumbridge. + #[must_use] + pub const fn is_reported(self) -> bool { + matches!(self, Self::ProviderReported | Self::HarnessReported) + } + + #[must_use] + pub const fn is_available(self) -> bool { + !matches!(self, Self::Unavailable) + } + + #[must_use] + pub const fn confidence(self) -> UsageConfidence { + match self { + Self::ProviderReported => UsageConfidence::Exact, + Self::HarnessReported | Self::LocallyMeasured => UsageConfidence::Approximate, + Self::Estimated => UsageConfidence::Projected, + Self::Unavailable => UsageConfidence::Unknown, + } + } + + /// The less authoritative of two provenances. + /// + /// A derived value is only as trustworthy as its weakest input. + #[must_use] + pub fn weakest(self, other: Self) -> Self { + self.max(other) + } +} + +/// How much a displayed number can be trusted. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UsageConfidence { + Exact, + Approximate, + Projected, + Unknown, +} + +impl UsageConfidence { + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Exact => "exact", + Self::Approximate => "approximate", + Self::Projected => "projected", + Self::Unknown => "unknown", + } + } +} + +/// What a usage number counts. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UsageUnit { + Tokens, + Requests, + /// A window fraction the provider reports directly, in permille. + WindowPermille, +} + +impl UsageUnit { + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Tokens => "tokens", + Self::Requests => "requests", + Self::WindowPermille => "window", + } + } + + /// Renders an amount in this unit without inflating its precision. + #[must_use] + pub fn format_amount(self, amount: u64) -> String { + match self { + Self::Tokens => format_count(amount, "tokens"), + Self::Requests => format_count(amount, "requests"), + Self::WindowPermille => format_permille(amount), + } + } +} + +fn format_count(amount: u64, suffix: &str) -> String { + if amount >= 1_000_000 { + format!( + "{}.{}M {suffix}", + amount / 1_000_000, + (amount % 1_000_000) / 100_000 + ) + } else if amount >= 1_000 { + format!("{}.{}k {suffix}", amount / 1_000, (amount % 1_000) / 100) + } else { + format!("{amount} {suffix}") + } +} + +fn format_permille(permille: u64) -> String { + format!("{}.{}%", permille / 10, permille % 10) +} + +/// Renders a duration for a compact surface. Never rounds up to a false zero. +#[must_use] +pub fn format_duration_ms(duration_ms: u64) -> String { + if duration_ms < MILLIS_PER_MINUTE { + return format!("{}s", duration_ms / MILLIS_PER_SECOND); + } + let minutes = duration_ms / MILLIS_PER_MINUTE; + if minutes < MINUTES_PER_HOUR { + return format!("{minutes}m"); + } + let hours = minutes / MINUTES_PER_HOUR; + if hours < HOURS_PER_DAY { + return format!("{hours}h {}m", minutes % MINUTES_PER_HOUR); + } + // Weekly and monthly quota windows are common, and "151h 30m" is not a + // duration anyone reads at a glance. + format!("{}d {}h", hours / HOURS_PER_DAY, hours % HOURS_PER_DAY) +} + +fn validate_identity(value: &str, label: &'static str) -> Result<(), UsageError> { + if value.trim().is_empty() + || value.len() > MAX_IDENTITY_BYTES + || value.chars().any(char::is_control) + { + return Err(UsageError::InvalidIdentifier(label)); + } + Ok(()) +} + +/// A stable identifier for one harness/provider/account combination. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct AccountProfileId(String); + +impl AccountProfileId { + /// Creates a stable, non-empty account profile identifier. + /// + /// # Errors + /// + /// Returns [`UsageError::InvalidIdentifier`] for an empty, oversized, or + /// control-bearing identifier. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_identity(&value, "account profile ID")?; + Ok(Self(value)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for AccountProfileId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +/// The active harness, provider, model, and account label shown in the footer. +/// +/// These are display names only. Credentials stay in the operating-system +/// credential store and are never part of this type. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountProfile { + id: AccountProfileId, + harness: String, + provider: String, + model: String, + account: String, +} + +impl AccountProfile { + /// Creates a footer-ready account profile. + /// + /// # Errors + /// + /// Returns [`UsageError::InvalidIdentifier`] when any field is empty, + /// oversized, or contains control characters. + pub fn new( + id: impl Into, + harness: impl Into, + provider: impl Into, + model: impl Into, + account: impl Into, + ) -> Result { + let harness = harness.into(); + let provider = provider.into(); + let model = model.into(); + let account = account.into(); + validate_identity(&harness, "harness name")?; + validate_identity(&provider, "provider name")?; + validate_identity(&model, "model name")?; + validate_identity(&account, "account label")?; + Ok(Self { + id: AccountProfileId::new(id)?, + harness, + provider, + model, + account, + }) + } + + #[must_use] + pub const fn id(&self) -> &AccountProfileId { + &self.id + } + + #[must_use] + pub fn harness(&self) -> &str { + &self.harness + } + + #[must_use] + pub fn provider(&self) -> &str { + &self.provider + } + + #[must_use] + pub fn model(&self) -> &str { + &self.model + } + + #[must_use] + pub fn account(&self) -> &str { + &self.account + } + + /// The footer's first answer: which harness, provider, and model is active. + #[must_use] + pub fn identity_line(&self) -> String { + format!("{} · {} · {}", self.harness, self.provider, self.model) + } +} + +/// A provider-declared usage window. +/// +/// The start is optional because providers report these fields independently: +/// a reset time can arrive without a window length. Knowing when a window ends +/// is the useful half — it bounds the exhaustion forecast and identifies which +/// window an observation belongs to — so discarding it for want of a start +/// would throw away a fact the provider actually stated. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct UsageWindow { + started_at_ms: Option, + resets_at_ms: u64, +} + +impl UsageWindow { + /// Creates a window that resets strictly after it starts. + /// + /// # Errors + /// + /// Returns [`UsageError::EmptyWindow`] when the reset is not after the start. + pub const fn new(started_at_ms: u64, resets_at_ms: u64) -> Result { + if resets_at_ms <= started_at_ms { + return Err(UsageError::EmptyWindow); + } + Ok(Self { + started_at_ms: Some(started_at_ms), + resets_at_ms, + }) + } + + /// Creates a window whose reset is known and whose start is not. + /// + /// Two of these compare equal when they reset at the same moment, which is + /// what lets a burn rate be scoped to one window without inventing a start. + #[must_use] + pub const fn until(resets_at_ms: u64) -> Self { + Self { + started_at_ms: None, + resets_at_ms, + } + } + + /// When the window opened, if the provider said. + #[must_use] + pub const fn started_at_ms(self) -> Option { + self.started_at_ms + } + + #[must_use] + pub const fn resets_at_ms(self) -> u64 { + self.resets_at_ms + } + + #[must_use] + pub const fn contains(self, at_ms: u64) -> bool { + if at_ms >= self.resets_at_ms { + return false; + } + match self.started_at_ms { + Some(started_at_ms) => at_ms >= started_at_ms, + None => true, + } + } + + #[must_use] + pub const fn has_expired(self, now_ms: u64) -> bool { + now_ms >= self.resets_at_ms + } + + #[must_use] + pub const fn remaining_ms(self, now_ms: u64) -> u64 { + self.resets_at_ms.saturating_sub(now_ms) + } +} + +/// One recorded usage fact. Observations are never edited, only appended. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UsageObservation { + profile: AccountProfileId, + unit: Option, + consumed: u64, + limit: Option, + window: Option, + provenance: UsageProvenance, + observed_at_ms: u64, +} + +impl UsageObservation { + /// Records a counted usage value with its provenance. + /// + /// # Errors + /// + /// Returns [`UsageError::UnavailableWithValue`] when the caller labels a + /// counted value as unavailable. Use [`UsageObservation::unavailable`] to + /// record a genuine gap. + pub fn counted( + profile: AccountProfileId, + unit: UsageUnit, + consumed: u64, + provenance: UsageProvenance, + observed_at_ms: u64, + ) -> Result { + if !provenance.is_available() { + return Err(UsageError::UnavailableWithValue); + } + Ok(Self { + profile, + unit: Some(unit), + consumed, + limit: None, + window: None, + provenance, + observed_at_ms, + }) + } + + /// Records that no usage value could be obtained at this moment. + /// + /// This is deliberately a first-class observation. Recording it stops the + /// projection from presenting an older reading as current. + #[must_use] + pub const fn unavailable(profile: AccountProfileId, observed_at_ms: u64) -> Self { + Self { + profile, + unit: None, + consumed: 0, + limit: None, + window: None, + provenance: UsageProvenance::Unavailable, + observed_at_ms, + } + } + + /// Attaches the quota ceiling this observation was measured against. + /// + /// # Errors + /// + /// Returns [`UsageError::ZeroLimit`] for a zero limit and + /// [`UsageError::UnavailableWithValue`] for an unavailable observation. + pub fn with_limit(mut self, limit: u64) -> Result { + if !self.provenance.is_available() { + return Err(UsageError::UnavailableWithValue); + } + if limit == 0 { + return Err(UsageError::ZeroLimit); + } + self.limit = Some(limit); + Ok(self) + } + + /// Attaches the provider window this observation belongs to. + /// + /// # Errors + /// + /// Returns [`UsageError::ObservationOutsideWindow`] when the observation + /// time is not inside the window, and [`UsageError::UnavailableWithValue`] + /// for an unavailable observation. + pub fn with_window(mut self, window: UsageWindow) -> Result { + if !self.provenance.is_available() { + return Err(UsageError::UnavailableWithValue); + } + if !window.contains(self.observed_at_ms) { + return Err(UsageError::ObservationOutsideWindow { + observed_at_ms: self.observed_at_ms, + }); + } + self.window = Some(window); + Ok(self) + } + + #[must_use] + pub const fn profile(&self) -> &AccountProfileId { + &self.profile + } + + #[must_use] + pub const fn unit(&self) -> Option { + self.unit + } + + #[must_use] + pub const fn consumed(&self) -> u64 { + self.consumed + } + + #[must_use] + pub const fn limit(&self) -> Option { + self.limit + } + + #[must_use] + pub const fn window(&self) -> Option { + self.window + } + + #[must_use] + pub const fn provenance(&self) -> UsageProvenance { + self.provenance + } + + #[must_use] + pub const fn observed_at_ms(&self) -> u64 { + self.observed_at_ms + } + + /// Units left before the ceiling, when a ceiling is known. + #[must_use] + pub fn remaining(&self) -> Option { + self.limit.map(|limit| limit.saturating_sub(self.consumed)) + } +} + +/// A bounded, append-only observation stream per account profile. +#[derive(Clone, Debug)] +pub struct UsageLedger { + retention_per_profile: usize, + staleness_budget_ms: u64, + observations: BTreeMap>, +} + +impl Default for UsageLedger { + fn default() -> Self { + Self::new() + } +} + +impl UsageLedger { + #[must_use] + pub fn new() -> Self { + Self { + retention_per_profile: DEFAULT_RETENTION_PER_PROFILE, + staleness_budget_ms: DEFAULT_STALENESS_BUDGET_MS, + observations: BTreeMap::new(), + } + } + + /// Creates a ledger that retains a bounded history per profile. + /// + /// # Errors + /// + /// Returns [`UsageError::RetentionTooSmall`] below two observations, which + /// is the minimum a burn rate can be derived from. + pub fn with_retention(retention_per_profile: usize) -> Result { + if retention_per_profile < MIN_RETENTION_PER_PROFILE { + return Err(UsageError::RetentionTooSmall); + } + Ok(Self { + retention_per_profile, + ..Self::new() + }) + } + + /// Sets how old the newest observation may be before it reads as stale. + #[must_use] + pub const fn with_staleness_budget_ms(mut self, staleness_budget_ms: u64) -> Self { + self.staleness_budget_ms = staleness_budget_ms; + self + } + + /// Appends an observation to its profile's history. + /// + /// # Errors + /// + /// Returns [`UsageError::OutOfOrderObservation`] when the observation is + /// older than the newest one already recorded for that profile. The stream + /// is append-only, so time must not move backwards. + pub fn record(&mut self, observation: UsageObservation) -> Result<(), UsageError> { + let history = self + .observations + .entry(observation.profile.clone()) + .or_default(); + if let Some(newest) = history.back() + && observation.observed_at_ms < newest.observed_at_ms + { + return Err(UsageError::OutOfOrderObservation { + observed_at_ms: observation.observed_at_ms, + newest_ms: newest.observed_at_ms, + }); + } + history.push_back(observation); + while history.len() > self.retention_per_profile { + history.pop_front(); + } + Ok(()) + } + + #[must_use] + pub fn latest(&self, profile: &AccountProfileId) -> Option<&UsageObservation> { + self.observations.get(profile).and_then(VecDeque::back) + } + + #[must_use] + pub fn observation_count(&self, profile: &AccountProfileId) -> usize { + self.observations.get(profile).map_or(0, VecDeque::len) + } + + pub fn profiles(&self) -> impl Iterator { + self.observations.keys() + } + + /// Derives the current view of a profile's usage. + /// + /// Reported facts keep their own provenance. Anything this method computes + /// is labelled [`UsageProvenance::Estimated`], even when every input was + /// provider-reported: a derived rate is not a reported fact. + #[must_use] + pub fn project(&self, profile: &AccountProfileId, now_ms: u64) -> UsageProjection { + let Some(history) = self.observations.get(profile) else { + return UsageProjection::unavailable(None); + }; + let Some(latest) = history.back() else { + return UsageProjection::unavailable(None); + }; + if !latest.provenance.is_available() { + return UsageProjection::unavailable(Some(latest.observed_at_ms)); + } + if let Some(window) = latest.window + && window.has_expired(now_ms) + { + return UsageProjection::window_rolled(latest.observed_at_ms); + } + + let consumed_permille = latest + .limit + .map(|limit| permille_of(latest.consumed, limit)) + .or_else(|| { + matches!(latest.unit, Some(UsageUnit::WindowPermille)) + .then_some(latest.consumed.min(MAX_PERMILLE)) + }); + let burn_per_hour = burn_per_hour(history, latest); + let resets_in_ms = latest.window.map(|window| window.remaining_ms(now_ms)); + let exhaustion_in_ms = burn_per_hour + .and_then(|rate| exhaustion_in_ms(latest.remaining()?, rate, resets_in_ms)); + + UsageProjection { + unit: latest.unit, + consumed: Some(latest.consumed), + limit: latest.limit, + consumed_permille, + resets_in_ms, + burn_per_hour, + exhaustion_in_ms, + provenance: latest.provenance, + stale: now_ms.saturating_sub(latest.observed_at_ms) > self.staleness_budget_ms, + window_rolled: false, + observed_at_ms: Some(latest.observed_at_ms), + } + } +} + +fn permille_of(consumed: u64, limit: u64) -> u64 { + if limit == 0 { + return 0; + } + let permille = u128::from(consumed) * PERMILLE_SCALE / u128::from(limit); + u64::try_from(permille) + .unwrap_or(MAX_PERMILLE) + .min(MAX_PERMILLE) +} + +/// Averages consumption across the newest observation's window. +/// +/// Returns `None` unless two observations share a window and a unit, are far +/// enough apart to divide by, and did not move backwards. A single reading +/// yields no rate at all rather than a rate of zero. +fn burn_per_hour(history: &VecDeque, latest: &UsageObservation) -> Option { + let baseline = history.iter().find(|candidate| { + candidate.window == latest.window + && candidate.unit == latest.unit + && candidate.provenance.is_available() + && candidate.observed_at_ms < latest.observed_at_ms + })?; + if latest.consumed < baseline.consumed { + return None; + } + let elapsed_ms = latest.observed_at_ms - baseline.observed_at_ms; + if elapsed_ms < MIN_BURN_SAMPLE_MS { + return None; + } + let consumed_delta = u128::from(latest.consumed - baseline.consumed); + let rate = consumed_delta * MILLIS_PER_HOUR / u128::from(elapsed_ms); + u64::try_from(rate).ok() +} + +/// Projects when a ceiling is reached, unless the window resets first. +fn exhaustion_in_ms(remaining: u64, burn_per_hour: u64, resets_in_ms: Option) -> Option { + if burn_per_hour == 0 { + return None; + } + let millis = u128::from(remaining) * MILLIS_PER_HOUR / u128::from(burn_per_hour); + let millis = u64::try_from(millis).ok()?; + match resets_in_ms { + Some(resets_in_ms) if millis >= resets_in_ms => None, + _ => Some(millis), + } +} + +/// The derived view a footer or detail panel renders. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct UsageProjection { + unit: Option, + consumed: Option, + limit: Option, + consumed_permille: Option, + resets_in_ms: Option, + burn_per_hour: Option, + exhaustion_in_ms: Option, + provenance: UsageProvenance, + stale: bool, + window_rolled: bool, + observed_at_ms: Option, +} + +impl UsageProjection { + const fn unavailable(observed_at_ms: Option) -> Self { + Self { + unit: None, + consumed: None, + limit: None, + consumed_permille: None, + resets_in_ms: None, + burn_per_hour: None, + exhaustion_in_ms: None, + provenance: UsageProvenance::Unavailable, + stale: false, + window_rolled: false, + observed_at_ms, + } + } + + const fn window_rolled(observed_at_ms: u64) -> Self { + Self { + stale: true, + window_rolled: true, + ..Self::unavailable(Some(observed_at_ms)) + } + } + + #[must_use] + pub const fn unit(self) -> Option { + self.unit + } + + #[must_use] + pub const fn consumed(self) -> Option { + self.consumed + } + + #[must_use] + pub const fn limit(self) -> Option { + self.limit + } + + /// Share of the window consumed, in permille, when a ceiling is known. + #[must_use] + pub const fn consumed_permille(self) -> Option { + self.consumed_permille + } + + #[must_use] + pub const fn resets_in_ms(self) -> Option { + self.resets_in_ms + } + + /// Units per hour averaged over the current window. + #[must_use] + pub const fn burn_per_hour(self) -> Option { + self.burn_per_hour + } + + /// Time until the ceiling is reached, absent unless it precedes the reset. + #[must_use] + pub const fn exhaustion_in_ms(self) -> Option { + self.exhaustion_in_ms + } + + /// The provenance of the reported facts in this projection. + #[must_use] + pub const fn provenance(self) -> UsageProvenance { + self.provenance + } + + /// The provenance of the burn rate, which is always derived. + #[must_use] + pub const fn burn_provenance(self) -> UsageProvenance { + match self.burn_per_hour { + Some(_) => UsageProvenance::Estimated, + None => UsageProvenance::Unavailable, + } + } + + /// The trust classification a detail view must keep. + #[must_use] + pub const fn confidence(self) -> UsageConfidence { + if self.stale { + return UsageConfidence::Unknown; + } + self.provenance.confidence() + } + + /// Whether the newest observation is older than the staleness budget. + #[must_use] + pub const fn is_stale(self) -> bool { + self.stale + } + + /// Whether the last known window ended before this projection was taken. + #[must_use] + pub const fn window_has_rolled(self) -> bool { + self.window_rolled + } + + #[must_use] + pub const fn observed_at_ms(self) -> Option { + self.observed_at_ms + } + + #[must_use] + pub const fn is_available(self) -> bool { + self.provenance.is_available() + } +} + +/// Renders the five footer answers for one profile. +/// +/// Every method returns an explicit unavailable phrase instead of a placeholder +/// number, so a missing fact reads as missing rather than as zero. +#[derive(Clone, Copy, Debug)] +pub struct FooterUsage<'a> { + profile: &'a AccountProfile, + projection: &'a UsageProjection, +} + +impl<'a> FooterUsage<'a> { + #[must_use] + pub const fn new(profile: &'a AccountProfile, projection: &'a UsageProjection) -> Self { + Self { + profile, + projection, + } + } + + /// Which harness, provider, model, and account profile is active. + #[must_use] + pub fn identity(&self) -> String { + format!( + "{} · {}", + self.profile.identity_line(), + self.profile.account() + ) + } + + /// How much of the current usage window has been consumed. + #[must_use] + pub fn window(&self) -> String { + match ( + self.projection.consumed_permille(), + self.projection.consumed(), + self.projection.unit(), + ) { + (Some(permille), _, _) => format!("{} used", format_permille(permille)), + (None, Some(consumed), Some(unit)) => { + format!( + "{} used · no ceiling reported", + unit.format_amount(consumed) + ) + } + _ if self.projection.window_has_rolled() => "window rolled over".to_owned(), + _ => "usage unavailable".to_owned(), + } + } + + /// When the window resets. + #[must_use] + pub fn reset(&self) -> String { + self.projection.resets_in_ms().map_or_else( + || "reset time unavailable".to_owned(), + |remaining| format!("resets in {}", format_duration_ms(remaining)), + ) + } + + /// The recent burn rate and, when meaningful, the projected exhaustion. + #[must_use] + pub fn burn(&self) -> String { + let Some(rate) = self.projection.burn_per_hour() else { + return "burn rate unavailable".to_owned(); + }; + let unit = self.projection.unit().unwrap_or(UsageUnit::Tokens); + let rate = format!("~{}/hr est", unit.format_amount(rate)); + self.projection + .exhaustion_in_ms() + .map_or(rate.clone(), |exhaustion| { + format!("{rate} · empty in {}", format_duration_ms(exhaustion)) + }) + } + + /// How trustworthy the numbers above are. + #[must_use] + pub fn trust(&self) -> String { + let provenance = self.projection.provenance(); + if !provenance.is_available() { + return if self.projection.window_has_rolled() { + "awaiting a new window reading".to_owned() + } else { + "no usage source".to_owned() + }; + } + let base = format!( + "{} · {}", + provenance.compact_label(), + self.projection.confidence().label() + ); + if self.projection.is_stale() { + return format!("{base} · stale"); + } + base + } +} + +impl fmt::Display for FooterUsage<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "{} · {} · {} · {} · {}", + self.identity(), + self.window(), + self.reset(), + self.burn(), + self.trust() + ) + } +} + +#[cfg(test)] +mod tests { + use super::{ + AccountProfile, AccountProfileId, FooterUsage, UsageConfidence, UsageError, UsageLedger, + UsageObservation, UsageProvenance, UsageUnit, UsageWindow, format_duration_ms, + }; + + const HOUR_MS: u64 = 3_600_000; + + fn profile() -> AccountProfile { + AccountProfile::new("codex-plus", "Codex", "ChatGPT", "gpt-5.2", "work") + .expect("the fixture profile is valid") + } + + fn window() -> UsageWindow { + UsageWindow::new(0, 5 * HOUR_MS).expect("the fixture window is valid") + } + + fn observation(consumed: u64, at_ms: u64, provenance: UsageProvenance) -> UsageObservation { + UsageObservation::counted( + AccountProfileId::new("codex-plus").expect("valid ID"), + UsageUnit::Tokens, + consumed, + provenance, + at_ms, + ) + .expect("a counted observation is available") + .with_limit(1_000_000) + .expect("a positive limit is valid") + .with_window(window()) + .expect("the observation is inside the window") + } + + fn ledger_with(samples: &[(u64, u64)]) -> UsageLedger { + let mut ledger = UsageLedger::new(); + for &(consumed, at_ms) in samples { + ledger + .record(observation( + consumed, + at_ms, + UsageProvenance::ProviderReported, + )) + .expect("fixture observations are ordered"); + } + ledger + } + + #[test] + fn provenance_labels_and_confidence_are_stable() { + assert_eq!( + UsageProvenance::ProviderReported.label(), + "provider-reported" + ); + assert_eq!(UsageProvenance::Estimated.compact_label(), "est"); + assert!(UsageProvenance::HarnessReported.is_reported()); + assert!(!UsageProvenance::LocallyMeasured.is_reported()); + assert_eq!( + UsageProvenance::ProviderReported.confidence(), + UsageConfidence::Exact + ); + assert_eq!( + UsageProvenance::Unavailable.confidence(), + UsageConfidence::Unknown + ); + } + + #[test] + fn the_weakest_provenance_wins() { + assert_eq!( + UsageProvenance::ProviderReported.weakest(UsageProvenance::Estimated), + UsageProvenance::Estimated + ); + assert_eq!( + UsageProvenance::Estimated.weakest(UsageProvenance::Unavailable), + UsageProvenance::Unavailable + ); + } + + #[test] + fn a_counted_value_cannot_be_labelled_unavailable() { + let error = UsageObservation::counted( + AccountProfileId::new("codex-plus").expect("valid ID"), + UsageUnit::Tokens, + 10, + UsageProvenance::Unavailable, + 0, + ) + .expect_err("unavailable counted values are rejected"); + assert_eq!(error, UsageError::UnavailableWithValue); + } + + #[test] + fn windows_and_limits_are_validated() { + assert_eq!(UsageWindow::new(10, 10), Err(UsageError::EmptyWindow)); + let observation = UsageObservation::counted( + AccountProfileId::new("codex-plus").expect("valid ID"), + UsageUnit::Tokens, + 10, + UsageProvenance::ProviderReported, + 9 * HOUR_MS, + ) + .expect("a counted observation is available"); + assert_eq!( + observation.clone().with_limit(0), + Err(UsageError::ZeroLimit) + ); + assert!(matches!( + observation.with_window(window()), + Err(UsageError::ObservationOutsideWindow { .. }) + )); + } + + #[test] + fn the_ledger_refuses_to_move_backwards() { + let mut ledger = ledger_with(&[(0, 0), (100, HOUR_MS)]); + let error = ledger + .record(observation( + 50, + HOUR_MS / 2, + UsageProvenance::ProviderReported, + )) + .expect_err("an older observation is rejected"); + assert!(matches!( + error, + UsageError::OutOfOrderObservation { newest_ms, .. } if newest_ms == HOUR_MS + )); + } + + #[test] + fn retention_is_bounded_and_keeps_the_newest_samples() { + let mut ledger = UsageLedger::with_retention(2).expect("two samples are allowed"); + for step in 0..5 { + ledger + .record(observation( + step * 1_000, + step * HOUR_MS / 4, + UsageProvenance::ProviderReported, + )) + .expect("ordered observations are accepted"); + } + let id = AccountProfileId::new("codex-plus").expect("valid ID"); + assert_eq!(ledger.observation_count(&id), 2); + assert_eq!( + ledger.latest(&id).map(UsageObservation::consumed), + Some(4_000) + ); + assert!(matches!( + UsageLedger::with_retention(1), + Err(UsageError::RetentionTooSmall) + )); + } + + #[test] + fn a_single_observation_yields_no_burn_rate() { + let ledger = ledger_with(&[(620_000, 0)]); + let projection = ledger.project(profile().id(), 0); + assert_eq!(projection.consumed_permille(), Some(620)); + assert_eq!(projection.burn_per_hour(), None, "a rate needs two samples"); + assert_eq!(projection.exhaustion_in_ms(), None); + } + + #[test] + fn burn_and_exhaustion_are_derived_from_the_window() { + let ledger = ledger_with(&[(0, 0), (300_000, HOUR_MS)]); + let projection = ledger.project(profile().id(), HOUR_MS); + assert_eq!(projection.burn_per_hour(), Some(300_000)); + assert_eq!(projection.consumed_permille(), Some(300)); + assert_eq!(projection.resets_in_ms(), Some(4 * HOUR_MS)); + assert_eq!( + projection.exhaustion_in_ms(), + Some(2 * HOUR_MS + 20 * 60_000), + "700k remaining at 300k/hr is reached before the four-hour reset" + ); + assert_eq!(projection.burn_provenance(), UsageProvenance::Estimated); + assert_eq!(projection.provenance(), UsageProvenance::ProviderReported); + } + + #[test] + fn exhaustion_is_withheld_when_the_window_resets_first() { + let ledger = ledger_with(&[(0, 0), (10_000, HOUR_MS)]); + let projection = ledger.project(profile().id(), HOUR_MS); + assert_eq!(projection.burn_per_hour(), Some(10_000)); + assert_eq!( + projection.exhaustion_in_ms(), + None, + "the window resets long before the ceiling is reached" + ); + } + + #[test] + fn an_unavailable_observation_invalidates_older_facts() { + let mut ledger = ledger_with(&[(0, 0), (200_000, HOUR_MS)]); + ledger + .record(UsageObservation::unavailable( + AccountProfileId::new("codex-plus").expect("valid ID"), + 2 * HOUR_MS, + )) + .expect("a gap is recordable"); + let projection = ledger.project(profile().id(), 2 * HOUR_MS); + assert!(!projection.is_available()); + assert_eq!(projection.consumed_permille(), None); + assert_eq!(projection.burn_per_hour(), None); + assert_eq!(projection.confidence(), UsageConfidence::Unknown); + } + + #[test] + fn an_expired_window_is_not_presented_as_current() { + let ledger = ledger_with(&[(0, 0), (200_000, HOUR_MS)]); + let projection = ledger.project(profile().id(), 6 * HOUR_MS); + assert!(projection.window_has_rolled()); + assert!(projection.is_stale()); + assert_eq!(projection.consumed_permille(), None); + assert_eq!(projection.burn_per_hour(), None); + } + + #[test] + fn an_unknown_profile_projects_as_unavailable() { + let ledger = UsageLedger::new(); + let projection = ledger.project(profile().id(), 0); + assert!(!projection.is_available()); + assert_eq!(projection.observed_at_ms(), None); + } + + #[test] + fn staleness_demotes_confidence_without_erasing_the_fact() { + let ledger = UsageLedger::new().with_staleness_budget_ms(1_000); + let mut ledger = ledger; + ledger + .record(observation(200_000, 0, UsageProvenance::ProviderReported)) + .expect("ordered"); + ledger + .record(observation( + 400_000, + HOUR_MS, + UsageProvenance::ProviderReported, + )) + .expect("ordered"); + let projection = ledger.project(profile().id(), HOUR_MS + 5_000); + assert!(projection.is_stale()); + assert_eq!(projection.consumed_permille(), Some(400)); + assert_eq!(projection.confidence(), UsageConfidence::Unknown); + } + + #[test] + fn the_footer_answers_five_questions() { + let ledger = ledger_with(&[(0, 0), (620_000, HOUR_MS)]); + let projection = ledger.project(profile().id(), HOUR_MS); + let profile = profile(); + let footer = FooterUsage::new(&profile, &projection); + assert_eq!(footer.identity(), "Codex · ChatGPT · gpt-5.2 · work"); + assert_eq!(footer.window(), "62.0% used"); + assert_eq!(footer.reset(), "resets in 4h 0m"); + assert!(footer.burn().starts_with("~620.0k tokens/hr est")); + assert_eq!(footer.trust(), "provider · exact"); + } + + #[test] + fn the_footer_says_unavailable_instead_of_zero() { + let ledger = UsageLedger::new(); + let projection = ledger.project(profile().id(), 0); + let profile = profile(); + let footer = FooterUsage::new(&profile, &projection); + assert_eq!(footer.window(), "usage unavailable"); + assert_eq!(footer.reset(), "reset time unavailable"); + assert_eq!(footer.burn(), "burn rate unavailable"); + assert_eq!(footer.trust(), "no usage source"); + assert!(!footer.to_string().contains('%')); + } + + #[test] + fn a_locally_measured_count_without_a_ceiling_says_so() { + let mut ledger = UsageLedger::new(); + ledger + .record( + UsageObservation::counted( + AccountProfileId::new("codex-plus").expect("valid ID"), + UsageUnit::Requests, + 42, + UsageProvenance::LocallyMeasured, + 0, + ) + .expect("available"), + ) + .expect("ordered"); + let projection = ledger.project(profile().id(), 0); + let profile = profile(); + let footer = FooterUsage::new(&profile, &projection); + assert_eq!(footer.window(), "42 requests used · no ceiling reported"); + assert_eq!(footer.trust(), "local · approximate"); + } + + #[test] + fn durations_render_without_false_precision() { + assert_eq!(format_duration_ms(45_000), "45s"); + assert_eq!(format_duration_ms(14 * 60_000), "14m"); + assert_eq!(format_duration_ms(2 * HOUR_MS + 14 * 60_000), "2h 14m"); + // A weekly quota window is a real provider shape, not a hypothetical. + assert_eq!(format_duration_ms(10_080 * 60_000), "7d 0h"); + assert_eq!(format_duration_ms(151 * HOUR_MS + 30 * 60_000), "6d 7h"); + } + + #[test] + fn a_window_can_know_its_reset_without_knowing_its_start() { + let window = UsageWindow::until(5 * HOUR_MS); + assert_eq!(window.started_at_ms(), None); + assert_eq!(window.resets_at_ms(), 5 * HOUR_MS); + assert!(window.contains(0), "an unknown start bounds nothing below"); + assert!(window.contains(5 * HOUR_MS - 1)); + assert!( + !window.contains(5 * HOUR_MS), + "the reset still bounds it above" + ); + assert!(window.has_expired(5 * HOUR_MS)); + assert_eq!(window.remaining_ms(HOUR_MS), 4 * HOUR_MS); + } + + #[test] + fn windows_with_the_same_reset_share_an_identity() { + assert_eq!(UsageWindow::until(HOUR_MS), UsageWindow::until(HOUR_MS)); + assert_ne!(UsageWindow::until(HOUR_MS), UsageWindow::until(2 * HOUR_MS)); + assert_ne!( + UsageWindow::until(HOUR_MS), + UsageWindow::new(0, HOUR_MS).expect("a bounded window is valid"), + "a known start is a different fact from an unknown one" + ); + } + + #[test] + fn a_start_only_window_still_scopes_a_burn_rate_to_one_period() { + // The provider reported a reset but no window length, then the quota + // rolled. A baseline must not be drawn across the two periods. + let id = AccountProfileId::new("codex-plus").expect("valid ID"); + let mut ledger = UsageLedger::new(); + for (consumed, at_ms, resets_at_ms) in [ + (800, 0, 5 * HOUR_MS), + (50, 5 * HOUR_MS + 1_000, 10 * HOUR_MS), + (850, 6 * HOUR_MS, 10 * HOUR_MS), + ] { + ledger + .record( + UsageObservation::counted( + id.clone(), + UsageUnit::WindowPermille, + consumed, + UsageProvenance::ProviderReported, + at_ms, + ) + .expect("available") + .with_limit(1_000) + .expect("a positive limit") + .with_window(UsageWindow::until(resets_at_ms)) + .expect("the observation precedes its reset"), + ) + .expect("ordered"); + } + let projection = ledger.project(&id, 6 * HOUR_MS); + assert_eq!(projection.resets_in_ms(), Some(4 * HOUR_MS)); + assert_eq!( + projection.burn_per_hour(), + Some(800), + "the baseline must be the reading from this window, not the last one" + ); + assert_eq!( + projection.exhaustion_in_ms(), + Some(675_000), + "150 permille left at 800 per hour is about eleven minutes" + ); + } + + #[test] + fn identities_reject_empty_and_control_values() { + assert!(matches!( + AccountProfile::new("id", "", "p", "m", "a"), + Err(UsageError::InvalidIdentifier("harness name")) + )); + assert!(matches!( + AccountProfileId::new("bad\nid"), + Err(UsageError::InvalidIdentifier("account profile ID")) + )); + } +} diff --git a/crates/lumbridge-core/src/workspace.rs b/crates/lumbridge-core/src/workspace.rs index 34a5804..902b808 100644 --- a/crates/lumbridge-core/src/workspace.rs +++ b/crates/lumbridge-core/src/workspace.rs @@ -464,6 +464,11 @@ impl WorkspaceState { /// Applies one idempotent command after checking its required capability. /// + /// Authority is checked before the replay table is consulted. A caller that + /// cannot perform a command also cannot learn whether its request ID was + /// already applied, so the retry path is not an oracle over the workspace's + /// history. + /// /// # Errors /// /// Rejects insufficient authority and invalid state transitions without @@ -473,6 +478,11 @@ impl WorkspaceState { request: WorkspaceRequest, granted: WorkspaceCapability, ) -> Result { + request.origin.validate()?; + let required = request.command.required_capability(); + if granted < required { + return Err(WorkspaceError::CapabilityDenied { required, granted }); + } if let Some(applied) = self.applied_requests.get(&request.request_id) { if applied == &request { return Ok(WorkspaceApplyOutcome { @@ -483,11 +493,6 @@ impl WorkspaceState { } return Err(WorkspaceError::RequestConflict(request.request_id)); } - request.origin.validate()?; - let required = request.command.required_capability(); - if granted < required { - return Err(WorkspaceError::CapabilityDenied { required, granted }); - } let event = self.apply_command(request.command.clone())?; self.applied_requests @@ -749,6 +754,33 @@ mod tests { assert_eq!(state.revision(), 1); } + #[test] + fn replaying_an_applied_request_still_requires_its_capability() { + let mut state = workspace(); + let command = request( + "launch-agent", + WorkspaceCommand::SplitPane { + target: id(PaneId::new, "root"), + pane: pane("agent", true), + axis: SplitAxis::Horizontal, + ratio: SplitRatio::default(), + placement: PanePlacement::After, + }, + ); + state + .apply(command.clone(), WorkspaceCapability::Execute) + .expect("execute may launch"); + // The replay table must not become an oracle: a caller holding less + // authority learns nothing about what has already been applied. + assert!(matches!( + state.apply(command, WorkspaceCapability::Observe), + Err(WorkspaceError::CapabilityDenied { + required: WorkspaceCapability::Execute, + granted: WorkspaceCapability::Observe, + }) + )); + } + #[test] fn reused_request_id_must_match_origin_and_command() { let mut state = workspace(); diff --git a/crates/lumbridge-harness/Cargo.toml b/crates/lumbridge-harness/Cargo.toml new file mode 100644 index 0000000..a329ca2 --- /dev/null +++ b/crates/lumbridge-harness/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "lumbridge-harness" +description = "Harness launch profiles and documented status/usage probes for Lumbridge" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +lumbridge-core = { path = "../lumbridge-core" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2.0" + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.28", features = ["process", "signal"] } + +[lints] +workspace = true diff --git a/crates/lumbridge-harness/src/child.rs b/crates/lumbridge-harness/src/child.rs new file mode 100644 index 0000000..e148c74 --- /dev/null +++ b/crates/lumbridge-harness/src/child.rs @@ -0,0 +1,324 @@ +//! A supervised, byte-capped stdio child. +//! +//! The child is launched with a cleared environment and a literal allowlist. +//! It speaks a machine protocol, so it gets pipes rather than a PTY: a TTY +//! would invite terminal control sequences in both directions for no benefit. +//! Its stderr is discarded rather than captured, because a harness's diagnostic +//! output is exactly the kind of text that ends up in a log carrying a path or +//! a token fragment. +//! +//! Reading a pipe blocks. The child therefore splits into three owners: a +//! reader that may block on its own thread, a writer, and a killer the +//! supervising thread keeps. Shutdown kills first and joins second, so a child +//! that has gone quiet — or hostile — can never hold the caller hostage. +//! +//! The child is put in its own process group and the group is what gets +//! killed. A pipe reports end of file only when *every* write end closes, so +//! killing the direct child alone is not enough: a launcher that execs the +//! real program as a grandchild with inherited stdio — which is how the npm +//! distribution of Codex works — leaves that grandchild holding our stdout, +//! and the reader would block forever on a pipe nothing will ever write to. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::{Arc, Mutex}; + +use crate::HarnessError; + +/// Environment a probe child may inherit. +/// +/// Literal names only: never a pattern and never a user-supplied name. +/// +/// This deliberately mirrors but does not reuse the terminal crate's private +/// allowlist. AGENTS.md keeps the harness and terminal boundaries separate, +/// and the two lists genuinely differ: a probe needs `CODEX_HOME` so it reads +/// the same account the user's panes use, and has no use for `SHELL`. +/// `CODEX_HOME` is a filesystem path, never a secret. +pub const PROBE_ENV_ALLOWLIST: &[&str] = &[ + "CODEX_HOME", + "HOME", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LOGNAME", + "PATH", + "TMPDIR", + "USER", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", +]; + +/// A single frame longer than this is treated as a fault rather than buffered. +/// A hostile or broken relay must not be able to grow this process's memory. +pub(crate) const MAX_LINE_BYTES: usize = 256 * 1024; + +/// A freshly launched child, before its pipes are handed to their owners. +pub(crate) struct ProbeChild { + child: Arc>, + group: u32, + stdin: ChildStdin, + stdout: ChildStdout, +} + +impl ProbeChild { + /// Launches a probe program with a minimal environment. + /// + /// This runs on the caller's thread, following the same pattern as the + /// runtime actor: start synchronously so a missing program is an immediate + /// error, then transfer the pipes to their threads. + /// + /// # Errors + /// + /// Returns [`HarnessError::EmptyProgram`] for a blank program name and + /// [`HarnessError::SpawnFailed`] if the process cannot start. The + /// underlying [`std::io::Error`] is deliberately reduced to its + /// [`std::io::ErrorKind`] so a filesystem path cannot travel in an error. + pub(crate) fn spawn(program: &str, arguments: &[&str]) -> Result { + if program.trim().is_empty() { + return Err(HarnessError::EmptyProgram); + } + let mut command = Command::new(program); + command + .args(arguments) + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // The child leads its own group, so its group ID is its process ID + // and every descendant it does not deliberately detach joins it. + command.process_group(0); + } + for name in PROBE_ENV_ALLOWLIST { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } + let mut child = command + .spawn() + .map_err(|error| HarnessError::SpawnFailed(error.kind()))?; + let broken = HarnessError::SpawnFailed(std::io::ErrorKind::BrokenPipe); + let group = child.id(); + let stdin = child.stdin.take().ok_or(broken)?; + let stdout = child.stdout.take().ok_or(broken)?; + Ok(Self { + child: Arc::new(Mutex::new(child)), + group, + stdin, + stdout, + }) + } + + /// Splits the child into its three independent owners. + pub(crate) fn into_parts(self) -> (ProbeWriter, ProbeReader, ProbeKiller) { + ( + ProbeWriter { stdin: self.stdin }, + ProbeReader { + reader: BufReader::new(self.stdout), + }, + ProbeKiller { + child: self.child, + group: self.group, + }, + ) + } +} + +/// The writing half. +pub(crate) struct ProbeWriter { + stdin: ChildStdin, +} + +impl ProbeWriter { + /// Writes one newline-terminated frame. + /// + /// # Errors + /// + /// Returns [`HarnessError::Eof`] once the child has closed its input. + pub(crate) fn write_line(&mut self, line: &str) -> Result<(), HarnessError> { + self.stdin + .write_all(line.as_bytes()) + .and_then(|()| self.stdin.write_all(b"\n")) + .and_then(|()| self.stdin.flush()) + .map_err(|_| HarnessError::Eof) + } +} + +/// The reading half. Its methods block, so it belongs on its own thread. +pub(crate) struct ProbeReader { + reader: BufReader, +} + +impl ProbeReader { + /// Reads one frame, refusing an oversized line instead of buffering it. + /// + /// # Errors + /// + /// Returns [`HarnessError::Eof`] at end of stream and + /// [`HarnessError::OversizedLine`] past [`MAX_LINE_BYTES`]. + pub(crate) fn read_line(&mut self) -> Result { + let mut buffer = Vec::new(); + let read = self + .reader + .by_ref() + .take(MAX_LINE_BYTES as u64 + 1) + .read_until(b'\n', &mut buffer) + .map_err(|_| HarnessError::Eof)?; + if read == 0 { + return Err(HarnessError::Eof); + } + if read > MAX_LINE_BYTES { + return Err(HarnessError::OversizedLine(read)); + } + String::from_utf8(buffer).map_err(|_| HarnessError::Malformed) + } +} + +/// The half a supervisor keeps so it can always stop the child. +#[derive(Clone)] +pub(crate) struct ProbeKiller { + child: Arc>, + group: u32, +} + +impl ProbeKiller { + /// Ends the child and everything in its process group, then reaps it. + /// + /// Killing the whole group is what closes every write end of our stdout + /// pipe, which is what unblocks the reader. Idempotent: killing an + /// already-dead process is not an error worth reporting, and a poisoned + /// lock means another thread died mid-kill, which the operating system has + /// already resolved. + pub(crate) fn kill(&self) { + self.kill_group(); + if let Ok(mut child) = self.child.lock() { + let _ = child.kill(); + let _ = child.wait(); + } + } + + #[cfg(unix)] + fn kill_group(&self) { + use nix::sys::signal::{Signal, killpg}; + use nix::unistd::Pid; + + if let Ok(group) = i32::try_from(self.group) { + let _ = killpg(Pid::from_raw(group), Signal::SIGKILL); + } + } + + #[cfg(not(unix))] + fn kill_group(&self) {} +} + +#[cfg(test)] +mod tests { + use super::{MAX_LINE_BYTES, PROBE_ENV_ALLOWLIST, ProbeChild}; + use crate::HarnessError; + + #[test] + fn an_empty_program_is_refused_before_spawning() { + assert!(matches!( + ProbeChild::spawn(" ", &[]).err(), + Some(HarnessError::EmptyProgram) + )); + } + + #[test] + fn a_missing_program_reports_a_kind_and_never_a_path() { + let Err(error) = ProbeChild::spawn("lumbridge-no-such-probe-binary", &[]) else { + panic!("a missing program cannot spawn"); + }; + assert!(matches!(error, HarnessError::SpawnFailed(_))); + assert!( + !error.to_string().contains("lumbridge-no-such-probe-binary"), + "an error must not carry a filesystem path" + ); + } + + #[test] + fn the_allowlist_is_literal_sorted_and_free_of_secret_bearing_names() { + let mut sorted = PROBE_ENV_ALLOWLIST.to_vec(); + sorted.sort_unstable(); + assert_eq!(sorted, PROBE_ENV_ALLOWLIST); + for name in PROBE_ENV_ALLOWLIST { + let upper = name.to_uppercase(); + assert!( + !upper.contains("TOKEN") + && !upper.contains("KEY") + && !upper.contains("SECRET") + && !upper.contains("PASSWORD"), + "{name} could carry a credential into a child" + ); + } + } + + #[test] + fn a_child_round_trips_one_frame() { + let Ok(child) = ProbeChild::spawn("cat", &[]) else { + return; // cat is not guaranteed on every supported platform. + }; + let (mut writer, mut reader, killer) = child.into_parts(); + writer + .write_line(r#"{"ok":true}"#) + .expect("cat accepts input"); + assert_eq!( + reader.read_line().expect("cat echoes the line"), + "{\"ok\":true}\n" + ); + killer.kill(); + } + + #[test] + fn an_oversized_frame_is_refused_rather_than_buffered() { + // Generated by the child so the test never deadlocks writing more than + // a pipe buffer into a process that is not reading. + let script = format!("head -c {} /dev/zero | tr '\\0' 'x'", MAX_LINE_BYTES + 16); + let Ok(child) = ProbeChild::spawn("sh", &["-c", &script]) else { + return; + }; + let (_writer, mut reader, killer) = child.into_parts(); + assert!(matches!( + reader.read_line(), + Err(HarnessError::OversizedLine(_)) + )); + killer.kill(); + } + + #[test] + fn killing_a_child_unblocks_a_reader_blocked_on_a_grandchild() { + // A launcher that backgrounds a helper and waits is the shape of the + // npm Codex distribution: the helper inherits our stdout, so killing + // only the direct child would leave the reader blocked forever. + let Ok(child) = ProbeChild::spawn("sh", &["-c", "sleep 30 & wait"]) else { + return; + }; + let (_writer, mut reader, killer) = child.into_parts(); + let reading = std::thread::spawn(move || reader.read_line()); + killer.kill(); + let outcome = reading.join().expect("the reader thread must not panic"); + assert!( + matches!(outcome, Err(HarnessError::Eof)), + "killing the group must end the read rather than hang it" + ); + } + + #[test] + fn killing_a_child_unblocks_its_reader() { + // `sleep` never writes, so the reader is blocked until the kill lands. + let Ok(child) = ProbeChild::spawn("sleep", &["30"]) else { + return; + }; + let (_writer, mut reader, killer) = child.into_parts(); + let reading = std::thread::spawn(move || reader.read_line()); + killer.kill(); + let outcome = reading.join().expect("the reader thread must not panic"); + assert!( + matches!(outcome, Err(HarnessError::Eof)), + "a killed child must end the read rather than hang it" + ); + } +} diff --git a/crates/lumbridge-harness/src/claude/mod.rs b/crates/lumbridge-harness/src/claude/mod.rs new file mode 100644 index 0000000..f037647 --- /dev/null +++ b/crates/lumbridge-harness/src/claude/mod.rs @@ -0,0 +1,836 @@ +//! The Claude Code usage adapter, over two different surfaces. +//! +//! **Subscription windows** ([`statusline`]) are the real quota. Claude Code +//! 2.1.80 and later pipe a `rate_limits` object carrying `five_hour` and +//! `seven_day` — each with `used_percentage` and `resets_at` — to the +//! configured `statusLine` command on every turn. Those are rate-limit headers +//! the CLI already received on its own API responses, so reading them costs +//! nothing and they are `ProviderReported`. Lumbridge does not call the +//! account's usage endpoint for them: that would mean reading Claude Code's +//! OAuth credential, which AGENTS.md forbids. The status line is pushed to a +//! command the user installs, so no credential is ever touched. +//! +//! **Token consumption** ([`transcript`]) comes from the session transcripts +//! Claude Code writes under `~/.claude/projects`. Those record the API's +//! `usage` object per assistant turn. A running total is the harness's own +//! record rather than a provider's statement of account, so it is +//! `HarnessReported`, and it carries no ceiling — spend is not a quota. +//! +//! Both are tail followers. Each remembers a byte offset and reads only what +//! was appended since, which is what makes the token total monotonic — the +//! ledger rejects an observation that moves a profile backwards. + +mod statusline; +mod transcript; + +use std::collections::BTreeMap; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use lumbridge_core::{AccountProfile, UsageObservation, UsageProvenance, UsageUnit}; + +use crate::HarnessError; +use crate::claude::statusline::parse_feed_line; +pub use crate::claude::statusline::{ClaudeWindowKind, WindowReading}; +use crate::claude::transcript::MAX_RECORD_BYTES; +use crate::clock::MonotonicWallClock; +use crate::probe::{ProbeHealth, ProbeOutcome, UsageProbe}; + +pub use transcript::TokenTally; + +/// Transcripts are appended to constantly; polling faster than this buys +/// nothing but disk churn. +const MIN_POLL_INTERVAL: Duration = Duration::from_secs(1); +/// How long the worker sleeps between filesystem scans. +const WORKER_TICK: Duration = Duration::from_millis(250); +/// A bound on how much of the tree one scan will walk. +const MAX_DEPTH: usize = 6; +const MAX_FILES_PER_SCAN: usize = 4_096; +/// A bound on how much any one poll will read, so a large backlog is absorbed +/// across several polls instead of stalling one. +const MAX_BYTES_PER_POLL: usize = 8 * 1024 * 1024; +const EVENT_QUEUE: usize = 64; + +/// Where and how often to read transcripts. +#[derive(Clone, Debug)] +pub struct ClaudeCodeProbeOptions { + /// The Claude Code projects root. Defaults to `$CLAUDE_CONFIG_DIR/projects` + /// or `~/.claude/projects`. + pub projects_root: PathBuf, + /// Where the Lumbridge status-line bridge appends rate-limit readings. + /// Absent until the user installs the bridge, which is why the windows read + /// as unavailable rather than as zero before then. + pub rate_limit_feed: PathBuf, + pub poll_interval: Duration, +} + +impl Default for ClaudeCodeProbeOptions { + fn default() -> Self { + Self { + projects_root: default_projects_root(), + rate_limit_feed: default_rate_limit_feed(), + poll_interval: Duration::from_secs(10), + } + } +} + +/// Where the status-line bridge writes, under Lumbridge's own data root rather +/// than inside the harness's configuration. +/// +/// `LUMBRIDGE_CLAUDE_FEED` overrides it, and must be read here as well as in the +/// bridge: the two halves have to resolve the same path or the bridge writes +/// somewhere the probe never looks and the windows read as unavailable forever. +#[must_use] +pub fn default_rate_limit_feed() -> PathBuf { + if let Some(configured) = std::env::var_os("LUMBRIDGE_CLAUDE_FEED") { + return PathBuf::from(configured); + } + let root = std::env::var_os("XDG_DATA_HOME").map_or_else( + || { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_default() + .join(".local") + .join("share") + }, + PathBuf::from, + ); + root.join("lumbridge").join("claude-rate-limits.jsonl") +} + +/// The documented default location of Claude Code's session transcripts. +#[must_use] +pub fn default_projects_root() -> PathBuf { + if let Some(configured) = std::env::var_os("CLAUDE_CONFIG_DIR") { + return PathBuf::from(configured).join("projects"); + } + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_default() + .join(".claude") + .join("projects") +} + +#[derive(Debug)] +enum WorkerEvent { + Observation(UsageObservation), + Health(ProbeHealth), +} + +/// A running Claude Code usage probe. +pub struct ClaudeCodeProbe { + profiles: Vec, + events: Option>, + stop: Arc, + worker: Option>, + health: ProbeHealth, + tokens: TokenTally, +} + +impl ClaudeCodeProbe { + /// The static, account-free profiles this probe reports under: one per + /// documented subscription window, plus the transcript token total. + /// + /// The windows come first because they are the quota — what a user means + /// by "how much do I have left" — and the token total is the supporting + /// fact about spend. + fn profiles_for() -> Result, HarnessError> { + let mut profiles = Vec::new(); + for kind in ClaudeWindowKind::ALL { + profiles.push( + AccountProfile::new( + kind.profile_id(), + "Claude Code", + "Anthropic", + kind.scope(), + "subscription", + ) + .map_err(|_| HarnessError::EmptyProgram)?, + ); + } + profiles.push( + AccountProfile::new( + "claude-code-transcripts", + "Claude Code", + "Anthropic", + "session transcripts", + "subscription", + ) + .map_err(|_| HarnessError::EmptyProgram)?, + ); + Ok(profiles) + } + + /// Starts the probe and its worker. + /// + /// A missing projects directory is not an error: the probe reports zero + /// and keeps looking, because Claude Code creates it on first use. + /// + /// # Errors + /// + /// Returns [`HarnessError::ProbeIntervalTooShort`] below one second. + pub fn start(options: ClaudeCodeProbeOptions) -> Result { + if options.poll_interval < MIN_POLL_INTERVAL { + return Err(HarnessError::ProbeIntervalTooShort); + } + let profiles = Self::profiles_for()?; + let worker_profiles = profiles.clone(); + let (event_sender, events) = mpsc::sync_channel(EVENT_QUEUE); + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = Arc::clone(&stop); + let worker = thread::Builder::new() + .name("lumbridge-claude-probe".to_owned()) + .spawn(move || { + run_worker(&options, &worker_profiles, &event_sender, &worker_stop); + }) + .map_err(|error| HarnessError::SpawnFailed(error.kind()))?; + + Ok(Self { + profiles, + events: Some(events), + stop, + worker: Some(worker), + health: ProbeHealth::Starting, + tokens: TokenTally::default(), + }) + } + + /// The token split behind the latest observation. + /// + /// The ledger holds only the total; this keeps the breakdown available for + /// a detail view without a second pass over the transcripts. + #[must_use] + pub const fn tokens(&self) -> TokenTally { + self.tokens + } +} + +impl UsageProbe for ClaudeCodeProbe { + fn poll(&mut self) -> ProbeOutcome { + let Some(events) = self.events.as_ref() else { + return ProbeOutcome::idle(self.health); + }; + let mut observations = Vec::new(); + loop { + match events.try_recv() { + Ok(WorkerEvent::Observation(observation)) => observations.push(observation), + Ok(WorkerEvent::Health(health)) => self.health = health, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + if !self.health.is_faulted() { + self.health = ProbeHealth::Stopped; + } + self.events = None; + break; + } + } + } + ProbeOutcome::new(observations, self.health) + } + + fn profiles(&self) -> &[AccountProfile] { + &self.profiles + } + + fn shutdown(&mut self) { + self.stop.store(true, Ordering::Relaxed); + self.events = None; + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + if !self.health.is_faulted() { + self.health = ProbeHealth::Stopped; + } + } +} + +impl Drop for ClaudeCodeProbe { + fn drop(&mut self) { + self.shutdown(); + } +} + +/// Per-transcript read position. The offset is what makes the total monotonic. +#[derive(Default)] +struct FollowState { + offsets: BTreeMap, + tokens: TokenTally, + /// Read position in the status-line feed, and the newest reading seen. + feed_offset: u64, + windows: BTreeMap<&'static str, WindowReading>, +} + +/// Reads whatever the status-line bridge appended and keeps the newest line. +/// +/// The feed is a state snapshot per line, not a stream of deltas, so only the +/// last complete line matters — an older one is superseded, never summed. +fn follow_feed(path: &Path, state: &mut FollowState, observed_at_ms: u64) { + let Ok(metadata) = std::fs::metadata(path) else { + return; + }; + let length = metadata.len(); + if length < state.feed_offset { + state.feed_offset = 0; + } + if length == state.feed_offset { + return; + } + let take = usize::try_from(length - state.feed_offset) + .unwrap_or(usize::MAX) + .min(MAX_BYTES_PER_POLL); + let Ok(mut file) = File::open(path) else { + return; + }; + if file.seek(SeekFrom::Start(state.feed_offset)).is_err() { + return; + } + let mut buffer = vec![0_u8; take]; + let Ok(read) = file.read(&mut buffer) else { + return; + }; + buffer.truncate(read); + let chunk = String::from_utf8_lossy(&buffer); + + let mut consumed = 0; + let mut newest = None; + for line in chunk.split_inclusive('\n') { + if !line.ends_with('\n') { + break; + } + consumed += line.len(); + if line.len() <= MAX_RECORD_BYTES + && let Some(readings) = parse_feed_line(line.trim_end(), observed_at_ms) + { + newest = Some(readings); + } + } + state.feed_offset += consumed as u64; + if let Some(readings) = newest { + for (kind, reading) in readings { + state.windows.insert(kind.profile_id(), reading); + } + } +} + +fn run_worker( + options: &ClaudeCodeProbeOptions, + profiles: &[AccountProfile], + events: &SyncSender, + stop: &AtomicBool, +) { + let token_profile = profiles + .iter() + .find(|profile| profile.id().as_str() == "claude-code-transcripts") + .cloned(); + let mut clock = MonotonicWallClock::start(); + let mut state = FollowState::default(); + let poll_interval_ms = u64::try_from(options.poll_interval.as_millis()).unwrap_or(u64::MAX); + let mut last_poll_ms = 0; + let mut last_emitted = TokenTally::default(); + let mut last_windows: BTreeMap<&'static str, WindowReading> = BTreeMap::new(); + let mut primed = false; + + loop { + if stop.load(Ordering::Relaxed) { + break; + } + let now_ms = clock.now_ms(); + // While priming, scan every tick to work through the backlog quickly. + // After that, respect the poll interval. + if primed && now_ms.saturating_sub(last_poll_ms) < poll_interval_ms { + thread::sleep(WORKER_TICK); + continue; + } + last_poll_ms = now_ms; + + let caught_up = scan(&options.projects_root, &mut state); + // The quota windows are independent of the transcript backlog: a + // status-line reading is a complete snapshot, so it can be reported + // immediately rather than waiting for priming. + follow_feed(&options.rate_limit_feed, &mut state, now_ms); + for kind in ClaudeWindowKind::ALL { + let Some(reading) = state.windows.get(kind.profile_id()).copied() else { + continue; + }; + if last_windows.get(kind.profile_id()) == Some(&reading) { + continue; + } + last_windows.insert(kind.profile_id(), reading); + let Some(profile) = profiles + .iter() + .find(|profile| profile.id().as_str() == kind.profile_id()) + else { + continue; + }; + let observation = window_observation(profile, reading, now_ms); + if matches!( + events.try_send(WorkerEvent::Observation(observation)), + Err(mpsc::TrySendError::Disconnected(_)) + ) { + return; + } + } + + // Nothing is reported until the follower has read every existing + // transcript to its end. A partially-read backlog looks exactly like + // an enormous burst of spend, and the ledger would derive a burn rate + // from it — one early run reported forty-six billion tokens an hour, + // which was catch-up, not usage. The first reading must be a true + // baseline before any rate can mean anything. + if !caught_up { + thread::sleep(WORKER_TICK); + continue; + } + if !primed { + primed = true; + let _ = events.try_send(WorkerEvent::Health(ProbeHealth::Ready)); + } + + // Only speak when the number changed. An unchanged total re-recorded + // every poll would flush the ledger's bounded history of real readings. + if state.tokens == last_emitted { + continue; + } + last_emitted = state.tokens; + let Some(profile) = token_profile.as_ref() else { + continue; + }; + let Ok(observation) = UsageObservation::counted( + profile.id().clone(), + UsageUnit::Tokens, + state.tokens.total(), + UsageProvenance::HarnessReported, + now_ms, + ) else { + continue; + }; + // No `with_limit` and no `with_window`: the transcript states what was + // spent and says nothing about a ceiling or a reset. Attaching either + // would be an invention. + if matches!( + events.try_send(WorkerEvent::Observation(observation)), + Err(mpsc::TrySendError::Disconnected(_)) + ) { + break; + } + } +} + +/// Turns a window reading into the observation the ledger should hold. +/// +/// A usable reading is `ProviderReported`: the CLI is relaying rate-limit +/// headers from its own API responses, not computing a number. An absent +/// window is an explicit gap — an account with no plan limits, or a window the +/// API has stopped reporting — never a zero. +fn window_observation( + profile: &AccountProfile, + reading: WindowReading, + observed_at_ms: u64, +) -> UsageObservation { + let WindowReading::Usable { permille, window } = reading else { + return UsageObservation::unavailable(profile.id().clone(), observed_at_ms); + }; + let Ok(observation) = UsageObservation::counted( + profile.id().clone(), + UsageUnit::WindowPermille, + permille, + UsageProvenance::ProviderReported, + observed_at_ms, + ) else { + return UsageObservation::unavailable(profile.id().clone(), observed_at_ms); + }; + let observation = observation + .with_limit(1_000) + .unwrap_or_else(|_| UsageObservation::unavailable(profile.id().clone(), observed_at_ms)); + let Some(window) = window else { + return observation; + }; + observation + .clone() + .with_window(window) + .unwrap_or(observation) +} + +/// Walks the projects tree and reads whatever was appended since last time. +/// +/// Returns whether every transcript was read to its end. A `false` means the +/// per-poll byte budget ran out with work remaining, and the running total is +/// therefore mid-catch-up rather than current. +fn scan(root: &Path, state: &mut FollowState) -> bool { + let mut transcripts = Vec::new(); + collect_transcripts(root, 0, &mut transcripts); + let mut budget = MAX_BYTES_PER_POLL; + let mut caught_up = true; + for path in transcripts { + if budget == 0 { + return false; + } + if !follow(&path, state, &mut budget) { + caught_up = false; + } + } + caught_up +} + +fn collect_transcripts(directory: &Path, depth: usize, found: &mut Vec) { + if depth > MAX_DEPTH || found.len() >= MAX_FILES_PER_SCAN { + return; + } + let Ok(entries) = std::fs::read_dir(directory) else { + return; + }; + for entry in entries.flatten() { + if found.len() >= MAX_FILES_PER_SCAN { + return; + } + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + // Deliberately not following symlinks: a link inside the projects tree + // could otherwise point this reader at an arbitrary file. + if file_type.is_dir() { + collect_transcripts(&path, depth + 1, found); + } else if file_type.is_file() && path.extension().is_some_and(|ext| ext == "jsonl") { + found.push(path); + } + } +} + +/// Reads one transcript from its remembered offset. +/// +/// Returns whether this file is now read to its end. +fn follow(path: &Path, state: &mut FollowState, budget: &mut usize) -> bool { + let offset = state.offsets.get(path).copied().unwrap_or(0); + let Ok(metadata) = std::fs::metadata(path) else { + return true; + }; + let length = metadata.len(); + if length < offset { + // The file shrank, so it is not the file we were reading. Start over + // rather than trusting an offset into different content. + state.offsets.insert(path.to_path_buf(), 0); + return false; + } + let pending = usize::try_from(length - offset).unwrap_or(usize::MAX); + if pending == 0 { + return true; + } + let take = pending.min(*budget); + + let Ok(mut file) = File::open(path) else { + return true; + }; + if file.seek(SeekFrom::Start(offset)).is_err() { + return true; + } + let mut buffer = vec![0_u8; take]; + let Ok(read) = file.read(&mut buffer) else { + return true; + }; + buffer.truncate(read); + // A transcript is UTF-8 JSON. A chunk boundary can split a multi-byte + // character, so decode lossily and stop at the last complete line — the + // offset only advances past whole records either way. + let chunk = String::from_utf8_lossy(&buffer); + let (tally, consumed) = transcript::consume_chunk(&chunk); + state.tokens.add(tally.tokens); + state + .offsets + .insert(path.to_path_buf(), offset + consumed as u64); + *budget = budget.saturating_sub(consumed); + // Caught up only if we took the whole remainder and parsed all of it. A + // trailing partial line counts as caught up: the record is still being + // written, and waiting for it is correct. + take == pending && pending.saturating_sub(consumed) < MAX_RECORD_BYTES +} + +#[cfg(test)] +mod tests { + use super::{ + ClaudeCodeProbe, ClaudeCodeProbeOptions, FollowState, TokenTally, default_projects_root, + scan, + }; + use crate::HarnessError; + use crate::probe::{ProbeHealth, UsageProbe}; + use lumbridge_core::{UsageProvenance, UsageUnit}; + use std::io::Write; + use std::path::PathBuf; + use std::time::Duration; + + const RECORD: &str = r#"{"type":"assistant","message":{"model":"claude-opus-5","content":[{"type":"text","text":"private"}],"usage":{"input_tokens":1,"output_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}}}"#; + + fn scratch(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "lumbridge-claude-probe-{}-{tag}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("project-a")).expect("scratch dir"); + root + } + + fn append(path: &PathBuf, times: usize) { + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .expect("transcript is writable"); + for _ in 0..times { + writeln!(file, "{RECORD}").expect("append"); + } + } + + #[test] + fn a_missing_projects_root_reports_zero_rather_than_failing() { + let mut state = FollowState::default(); + scan( + &PathBuf::from("/nonexistent/lumbridge/claude/root"), + &mut state, + ); + assert_eq!(state.tokens, TokenTally::default()); + } + + #[test] + fn appended_records_accumulate_and_never_double_count() { + let root = scratch("accumulate"); + let transcript = root.join("project-a").join("session.jsonl"); + append(&transcript, 2); + + let mut state = FollowState::default(); + assert!(scan(&root, &mut state)); + assert_eq!(state.tokens.total(), 2 * 1_111); + + // A second scan with no new bytes must add nothing. + assert!(scan(&root, &mut state)); + assert_eq!(state.tokens.total(), 2 * 1_111); + + // Only the newly appended record is counted. + append(&transcript, 1); + assert!(scan(&root, &mut state)); + assert_eq!(state.tokens.total(), 3 * 1_111); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn nested_subagent_transcripts_are_counted_too() { + let root = scratch("nested"); + let nested = root.join("project-a").join("session").join("subagents"); + std::fs::create_dir_all(&nested).expect("nested dirs"); + append(&root.join("project-a").join("session.jsonl"), 1); + append(&nested.join("agent-1.jsonl"), 1); + + let mut state = FollowState::default(); + assert!(scan(&root, &mut state)); + assert_eq!( + state.tokens.total(), + 2 * 1_111, + "a subagent's tokens are the user's tokens" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn a_truncated_transcript_restarts_rather_than_reading_a_stale_offset() { + let root = scratch("truncated"); + let transcript = root.join("project-a").join("session.jsonl"); + append(&transcript, 3); + let mut state = FollowState::default(); + assert!(scan(&root, &mut state)); + let before = state.tokens.total(); + assert_eq!(before, 3 * 1_111); + + std::fs::write(&transcript, "").expect("truncate"); + // The shrink is reported as not-caught-up on purpose: the offset was + // just reset, so the probe must re-scan before it trusts the total + // again rather than reporting mid-reset. + assert!( + !scan(&root, &mut state), + "a shrunk file asks to be read again" + ); + assert!(scan(&root, &mut state), "and is caught up on the next pass"); + assert_eq!( + state.tokens.total(), + before, + "a shrunk file resets its offset without inventing or losing tokens" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn non_transcript_files_are_ignored() { + let root = scratch("ignored"); + std::fs::write(root.join("project-a").join("notes.md"), RECORD).expect("write"); + std::fs::write(root.join("project-a").join("history.json"), RECORD).expect("write"); + let mut state = FollowState::default(); + assert!(scan(&root, &mut state)); + assert_eq!(state.tokens, TokenTally::default()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn a_too_frequent_poll_interval_is_refused() { + let options = ClaudeCodeProbeOptions { + poll_interval: Duration::from_millis(10), + ..ClaudeCodeProbeOptions::default() + }; + assert_eq!( + ClaudeCodeProbe::start(options).err(), + Some(HarnessError::ProbeIntervalTooShort) + ); + } + + #[test] + fn the_probe_reports_harness_reported_tokens_with_no_ceiling() { + let root = scratch("probe"); + append(&root.join("project-a").join("session.jsonl"), 4); + let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions { + projects_root: root.clone(), + rate_limit_feed: root.join("feed.jsonl"), + poll_interval: Duration::from_secs(1), + }) + .expect("the probe starts"); + + let mut observations = Vec::new(); + for _ in 0..200 { + observations.extend(probe.poll().into_observations()); + if !observations.is_empty() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + probe.shutdown(); + let _ = std::fs::remove_dir_all(&root); + + let observation = observations.first().expect("a reading arrives"); + assert_eq!(observation.provenance(), UsageProvenance::HarnessReported); + assert_eq!(observation.unit(), Some(UsageUnit::Tokens)); + assert_eq!(observation.consumed(), 4 * 1_111); + assert_eq!( + observation.limit(), + None, + "a transcript states spend, never a ceiling" + ); + assert_eq!( + observation.window(), + None, + "a transcript states spend, never a reset" + ); + } + + #[test] + fn a_status_line_feed_becomes_provider_reported_windows() { + let root = scratch("windows"); + let feed = root.join("feed.jsonl"); + // The shape the bridge writes, with a reset far enough ahead that the + // window is still open when the probe reads it. + let resets_at = (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("a clock after the epoch") + .as_secs()) + + 3_600; + std::fs::write( + &feed, + format!( + "{{\"rate_limits_available\":true,\"rate_limits\":{{\"five_hour\":{{\"used_percentage\":42,\"resets_at\":{resets_at}}},\"seven_day\":{{\"used_percentage\":8.5,\"resets_at\":{resets_at}}}}}}}\n" + ), + ) + .expect("feed is writable"); + + let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions { + projects_root: root.clone(), + rate_limit_feed: feed, + poll_interval: Duration::from_secs(1), + }) + .expect("the probe starts"); + + let mut observations = Vec::new(); + for _ in 0..200 { + observations.extend(probe.poll().into_observations()); + if observations.len() >= 2 { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + probe.shutdown(); + let _ = std::fs::remove_dir_all(&root); + + let five = observations + .iter() + .find(|observation| observation.profile().as_str() == "claude-code-five-hour") + .expect("the five-hour window is reported"); + assert_eq!( + five.provenance(), + UsageProvenance::ProviderReported, + "the CLI relays the provider's own rate-limit headers" + ); + assert_eq!(five.unit(), Some(UsageUnit::WindowPermille)); + assert_eq!(five.consumed(), 420); + assert_eq!( + five.limit(), + Some(1_000), + "a percentage is a share of one whole" + ); + assert!(five.window().is_some(), "a documented window has a reset"); + } + + #[test] + fn an_account_without_plan_limits_reports_a_gap_not_a_zero() { + let root = scratch("nolimits"); + let feed = root.join("feed.jsonl"); + std::fs::write(&feed, "{\"rate_limits_available\":false}\n").expect("writable"); + let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions { + projects_root: root.clone(), + rate_limit_feed: feed, + poll_interval: Duration::from_secs(1), + }) + .expect("starts"); + let mut observations = Vec::new(); + for _ in 0..200 { + observations.extend(probe.poll().into_observations()); + if !observations.is_empty() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + probe.shutdown(); + let _ = std::fs::remove_dir_all(&root); + let window = observations + .iter() + .find(|observation| observation.profile().as_str() == "claude-code-five-hour") + .expect("the window is still named"); + assert_eq!(window.provenance(), UsageProvenance::Unavailable); + } + + #[test] + fn shutdown_is_prompt_and_idempotent() { + let root = scratch("shutdown"); + let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions { + projects_root: root.clone(), + rate_limit_feed: root.join("feed.jsonl"), + poll_interval: Duration::from_secs(60), + }) + .expect("starts"); + let started = std::time::Instant::now(); + probe.shutdown(); + probe.shutdown(); + assert!(started.elapsed() < Duration::from_secs(5)); + assert!(matches!( + probe.poll().health(), + ProbeHealth::Stopped | ProbeHealth::Starting | ProbeHealth::Ready + )); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn the_default_root_follows_the_documented_location() { + let root = default_projects_root(); + assert!(root.ends_with("projects")); + } +} diff --git a/crates/lumbridge-harness/src/claude/statusline.rs b/crates/lumbridge-harness/src/claude/statusline.rs new file mode 100644 index 0000000..9cc14b0 --- /dev/null +++ b/crates/lumbridge-harness/src/claude/statusline.rs @@ -0,0 +1,337 @@ +//! Claude Code's status-line rate-limit payload: the subscription window. +//! +//! Claude Code 2.1.80 and later pipe a `rate_limits` object to the configured +//! `statusLine` command on every turn. The shape is documented inside the CLI +//! itself, which describes `five_hour` and `seven_day` as +//! +//! ```text +//! "five_hour": { // present only while the API reports it and its resets_at has not passed +//! "used_percentage": number, // Percentage of limit used (0-100) +//! "resets_at": number // Unix epoch seconds when this window resets +//! } +//! ``` +//! +//! These are the real quota windows, and they cost nothing to read: the CLI is +//! relaying rate-limit headers that already came back on its own API responses. +//! That makes them `ProviderReported` under decision 0013's test — the harness +//! forwards the provider's number rather than computing one. +//! +//! `rate_limits_available` is the honest-gap signal, and the CLI documents it: +//! "False when plan rate limits do not apply (API key, Bedrock, Vertex, or +//! missing profile scope) — `rate_limits` will be null." An API-key user has no +//! subscription window, and this field says so rather than leaving us guessing. +//! +//! Nothing else from the status-line payload is modelled. It also carries the +//! session's cost, its transcript path, the current working directory, and the +//! model — none of which this parser can represent. + +use lumbridge_core::UsageWindow; +use serde::Deserialize; + +/// Percent is 0-100 on the wire; a spend limit may exceed 100 once breached. +const MAX_PERCENT: f64 = 100.0; +const PERMILLE_PER_PERCENT: f64 = 10.0; +const MILLIS_PER_SECOND: i64 = 1_000; +/// Documented window lengths, used to give each reading a window start so the +/// ledger can scope a burn rate to one quota period. +const FIVE_HOUR_MS: u64 = 5 * 60 * 60 * 1_000; +const SEVEN_DAY_MS: u64 = 7 * 24 * 60 * 60 * 1_000; + +/// One line of the feed a Lumbridge status-line bridge appends. +#[derive(Debug, Deserialize)] +pub(crate) struct FeedLineWire { + #[serde(default)] + pub(crate) rate_limits_available: Option, + #[serde(default)] + pub(crate) rate_limits: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub(crate) struct RateLimitsWire { + #[serde(default)] + pub(crate) five_hour: Option, + #[serde(default)] + pub(crate) seven_day: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct WindowWire { + /// The documented field. `utilization` is accepted as a sibling because the + /// OAuth usage endpoint spells the same quantity that way, and a rename + /// should degrade rather than silently go dark. + #[serde(default)] + pub(crate) used_percentage: Option, + #[serde(default)] + pub(crate) utilization: Option, + #[serde(default)] + pub(crate) resets_at: Option, +} + +/// Which documented subscription window a reading describes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClaudeWindowKind { + FiveHour, + SevenDay, +} + +impl ClaudeWindowKind { + pub(crate) const ALL: [Self; 2] = [Self::FiveHour, Self::SevenDay]; + + /// Static, account-free profile identifiers. + pub(crate) const fn profile_id(self) -> &'static str { + match self { + Self::FiveHour => "claude-code-five-hour", + Self::SevenDay => "claude-code-seven-day", + } + } + + /// Named for the window, not for a model: these limits are account-scoped. + pub(crate) const fn scope(self) -> &'static str { + match self { + Self::FiveHour => "five-hour window", + Self::SevenDay => "seven-day window", + } + } + + const fn length_ms(self) -> u64 { + match self { + Self::FiveHour => FIVE_HOUR_MS, + Self::SevenDay => SEVEN_DAY_MS, + } + } +} + +/// What one window in the feed means at a point in time. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WindowReading { + /// A usable share of the window. + Usable { + permille: u64, + window: Option, + }, + /// The window is absent, or the account has no plan limits at all. + Absent, +} + +/// Reads one window against the moment it was observed. +/// +/// Total: every input produces a decision and none of them panics. +pub(crate) fn parse_window( + wire: &WindowWire, + kind: ClaudeWindowKind, + observed_at_ms: u64, +) -> WindowReading { + let Some(percent) = wire.used_percentage.or(wire.utilization) else { + return WindowReading::Absent; + }; + if !percent.is_finite() || percent < 0.0 { + return WindowReading::Absent; + } + // A spend limit can report over 100 once breached; a quota share cannot + // exceed the whole, so clamp rather than letting the ledger hold >100%. + // + // The value is clamped to 0..=100 and scaled by ten before rounding, so it + // is a whole number in 0..=1000 — well inside u64 and inside the range f64 + // represents exactly. The conversion cannot truncate or lose a sign here. + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "a rounded whole number clamped to 0..=1000" + )] + let permille = (percent.clamp(0.0, MAX_PERCENT) * PERMILLE_PER_PERCENT).round() as u64; + + let window = wire + .resets_at + .and_then(unix_seconds_to_millis) + // The CLI documents that a window is present only while its resets_at + // has not passed. One that has is stale, so its start-and-reset pair is + // dropped and only the percentage survives. + .filter(|resets_at_ms| *resets_at_ms > observed_at_ms) + .and_then(|resets_at_ms| { + let started_at_ms = resets_at_ms.saturating_sub(kind.length_ms()); + if started_at_ms <= observed_at_ms { + UsageWindow::new(started_at_ms, resets_at_ms).ok() + } else { + // The local clock disagrees with the provider about the start. + // Keep the reset, which is the half that bounds a forecast. + Some(UsageWindow::until(resets_at_ms)) + } + }); + + WindowReading::Usable { permille, window } +} + +fn unix_seconds_to_millis(seconds: i64) -> Option { + u64::try_from(seconds.checked_mul(MILLIS_PER_SECOND)?).ok() +} + +/// Reads one feed line into a reading per documented window. +/// +/// `rate_limits_available: false` is a positive statement that this account has +/// no plan limits, so every window reads as absent rather than unknown. +pub(crate) fn parse_feed_line( + line: &str, + observed_at_ms: u64, +) -> Option<[(ClaudeWindowKind, WindowReading); 2]> { + let feed: FeedLineWire = serde_json::from_str(line).ok()?; + if feed.rate_limits_available == Some(false) { + return Some([ + (ClaudeWindowKind::FiveHour, WindowReading::Absent), + (ClaudeWindowKind::SevenDay, WindowReading::Absent), + ]); + } + let limits = feed.rate_limits?; + let five = limits + .five_hour + .as_ref() + .map_or(WindowReading::Absent, |w| { + parse_window(w, ClaudeWindowKind::FiveHour, observed_at_ms) + }); + let seven = limits + .seven_day + .as_ref() + .map_or(WindowReading::Absent, |w| { + parse_window(w, ClaudeWindowKind::SevenDay, observed_at_ms) + }); + Some([ + (ClaudeWindowKind::FiveHour, five), + (ClaudeWindowKind::SevenDay, seven), + ]) +} + +#[cfg(test)] +mod tests { + use super::{ClaudeWindowKind, WindowReading, parse_feed_line}; + + const HOUR_MS: u64 = 3_600_000; + const RESETS_AT_SECONDS: i64 = 1_800_000_000; + const RESETS_AT_MS: u64 = 1_800_000_000_000; + + fn line(five: &str, seven: &str) -> String { + format!( + r#"{{"rate_limits_available":true,"rate_limits":{{"five_hour":{five},"seven_day":{seven}}}}}"# + ) + } + + #[test] + fn the_documented_shape_yields_both_windows() { + let body = line( + &format!(r#"{{"used_percentage":42,"resets_at":{RESETS_AT_SECONDS}}}"#), + &format!(r#"{{"used_percentage":8.5,"resets_at":{RESETS_AT_SECONDS}}}"#), + ); + let readings = parse_feed_line(&body, RESETS_AT_MS - HOUR_MS).expect("a valid feed line"); + let WindowReading::Usable { permille, window } = readings[0].1 else { + panic!("the five-hour window is usable"); + }; + assert_eq!(readings[0].0, ClaudeWindowKind::FiveHour); + assert_eq!(permille, 420); + let window = window.expect("a documented window length builds a window"); + assert_eq!(window.resets_at_ms(), RESETS_AT_MS); + assert_eq!( + window.started_at_ms(), + Some(RESETS_AT_MS - 5 * HOUR_MS), + "the five-hour window starts five hours before it resets" + ); + + let WindowReading::Usable { permille, .. } = readings[1].1 else { + panic!("the seven-day window is usable"); + }; + assert_eq!(permille, 85, "a fractional percent keeps its tenth"); + } + + #[test] + fn an_api_key_account_reports_absent_rather_than_zero() { + // The CLI documents rate_limits_available as false for API key, + // Bedrock, Vertex, or a missing profile scope. + let readings = parse_feed_line( + r#"{"rate_limits_available":false,"rate_limits":null}"#, + RESETS_AT_MS, + ) + .expect("a valid feed line"); + assert!(readings.iter().all(|(_, r)| *r == WindowReading::Absent)); + } + + #[test] + fn a_missing_window_is_absent_without_affecting_the_other() { + let body = line( + &format!(r#"{{"used_percentage":10,"resets_at":{RESETS_AT_SECONDS}}}"#), + "null", + ); + let readings = parse_feed_line(&body, RESETS_AT_MS - HOUR_MS).expect("valid"); + assert!(matches!(readings[0].1, WindowReading::Usable { .. })); + assert_eq!(readings[1].1, WindowReading::Absent); + } + + #[test] + fn a_reset_that_has_passed_keeps_the_percentage_and_drops_the_window() { + let body = line( + &format!(r#"{{"used_percentage":99,"resets_at":{RESETS_AT_SECONDS}}}"#), + "null", + ); + let readings = parse_feed_line(&body, RESETS_AT_MS + 1).expect("valid"); + assert_eq!( + readings[0].1, + WindowReading::Usable { + permille: 990, + window: None + }, + "a window whose reset has passed is stale, not current" + ); + } + + #[test] + fn the_oauth_spelling_of_the_same_field_is_accepted() { + let body = line( + &format!(r#"{{"utilization":25,"resets_at":{RESETS_AT_SECONDS}}}"#), + "null", + ); + let readings = parse_feed_line(&body, RESETS_AT_MS - HOUR_MS).expect("valid"); + assert!(matches!( + readings[0].1, + WindowReading::Usable { permille: 250, .. } + )); + } + + #[test] + fn an_over_range_or_nonsense_percentage_is_clamped_or_refused() { + let over = line(r#"{"used_percentage":140}"#, "null"); + assert!(matches!( + parse_feed_line(&over, 0).expect("valid")[0].1, + WindowReading::Usable { + permille: 1_000, + window: None + } + )); + for bad in ["-1", "null"] { + let body = line(&format!(r#"{{"used_percentage":{bad}}}"#), "null"); + assert_eq!( + parse_feed_line(&body, 0).expect("valid")[0].1, + WindowReading::Absent + ); + } + } + + #[test] + fn malformed_or_unrelated_lines_are_refused() { + for body in ["", "not json", "{}", r#"{"rate_limits":{}}"#] { + let parsed = parse_feed_line(body, 0); + assert!( + parsed.is_none_or(|readings| readings + .iter() + .all(|(_, r)| *r == WindowReading::Absent)), + "{body:?} must not produce a number" + ); + } + } + + #[test] + fn window_kinds_have_distinct_account_free_identifiers() { + assert_ne!( + ClaudeWindowKind::FiveHour.profile_id(), + ClaudeWindowKind::SevenDay.profile_id() + ); + for kind in ClaudeWindowKind::ALL { + assert!(!kind.profile_id().contains('@')); + } + } +} diff --git a/crates/lumbridge-harness/src/claude/transcript.rs b/crates/lumbridge-harness/src/claude/transcript.rs new file mode 100644 index 0000000..c21b5f7 --- /dev/null +++ b/crates/lumbridge-harness/src/claude/transcript.rs @@ -0,0 +1,286 @@ +//! The Claude Code transcript reader: token counts in, nothing else out. +//! +//! Claude Code writes one JSONL record per event to +//! `~/.claude/projects//.jsonl`. Assistant records carry +//! the API's `usage` object verbatim, which is the only documented local +//! surface that reports what the harness actually spent. +//! +//! **These files contain the user's conversations.** This module is built so +//! that reading one cannot surface their content: the wire types below model +//! the record type, the model name, and the four token counters, and nothing +//! else. Serde discards every unmodelled field while parsing, so message text, +//! tool inputs, and file contents are never deserialized into a Lumbridge value +//! at all. That is a structural guarantee, not a convention — there is no +//! field here that could hold them. + +use serde::Deserialize; + +/// A transcript line is a single JSON object. Anything longer than this is not +/// a record we can use, and buffering it would let one file grow this process. +pub(crate) const MAX_RECORD_BYTES: usize = 1024 * 1024; + +/// The only fields this crate reads from a transcript record. +#[derive(Debug, Deserialize)] +pub(crate) struct RecordWire { + #[serde(rename = "type")] + pub(crate) record_type: Option, + #[serde(default)] + pub(crate) message: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct MessageWire { + #[serde(default)] + pub(crate) model: Option, + #[serde(default)] + pub(crate) usage: Option, +} + +/// The API usage object as Claude Code records it. +/// +/// `cache_creation`, `server_tool_use`, `iterations`, `service_tier`, `speed`, +/// and `inference_geo` are all present on the wire and all deliberately +/// unmodelled: a probe should not be able to read account or routing metadata +/// it has no use for. +/// +/// The wire names are pinned with explicit renames so the Rust field names can +/// read naturally without the contract drifting from what Claude Code writes. +#[derive(Debug, Default, Deserialize)] +pub(crate) struct UsageWire { + #[serde(default, rename = "input_tokens")] + pub(crate) input: u64, + #[serde(default, rename = "output_tokens")] + pub(crate) output: u64, + #[serde(default, rename = "cache_creation_input_tokens")] + pub(crate) cache_creation: u64, + #[serde(default, rename = "cache_read_input_tokens")] + pub(crate) cache_read: u64, +} + +/// Tokens the harness recorded, kept split by kind. +/// +/// The four counters are priced very differently and mean different things, so +/// they are carried separately rather than collapsed at the point of parsing. +/// The ledger holds one number per profile; [`TokenTally::total`] is what it +/// gets, and the split stays available for a detail view. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TokenTally { + pub input: u64, + pub output: u64, + pub cache_creation: u64, + pub cache_read: u64, +} + +impl TokenTally { + /// Every token the API processed. + /// + /// Cache reads are included because they are billed and do count against a + /// subscription window, even though they are re-reads of context already + /// counted once. Excluding them would understate consumption; weighting + /// them by price would require a price list this adapter does not have and + /// would turn a counted fact into an estimate. + #[must_use] + pub const fn total(self) -> u64 { + self.input + .saturating_add(self.output) + .saturating_add(self.cache_creation) + .saturating_add(self.cache_read) + } + + #[must_use] + pub const fn is_empty(self) -> bool { + self.total() == 0 + } + + pub(crate) fn add(&mut self, other: Self) { + self.input = self.input.saturating_add(other.input); + self.output = self.output.saturating_add(other.output); + self.cache_creation = self.cache_creation.saturating_add(other.cache_creation); + self.cache_read = self.cache_read.saturating_add(other.cache_read); + } +} + +/// What one transcript line contributed. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct RecordTally { + pub(crate) tokens: TokenTally, + /// The model that produced the record, when it named one. + pub(crate) model: Option, +} + +/// Reads one transcript line. +/// +/// Total: a line that is not JSON, not an assistant record, or carries no +/// usage contributes nothing. A malformed line is skipped rather than faulting +/// the probe, because a transcript is append-only and the last line of a file +/// being written is routinely incomplete. +pub(crate) fn parse_record(line: &str) -> Option { + let record: RecordWire = serde_json::from_str(line).ok()?; + if record.record_type.as_deref() != Some("assistant") { + return None; + } + let message = record.message?; + let usage = message.usage?; + let tokens = TokenTally { + input: usage.input, + output: usage.output, + cache_creation: usage.cache_creation, + cache_read: usage.cache_read, + }; + if tokens.is_empty() { + return None; + } + Some(RecordTally { + tokens, + model: message.model, + }) +} + +/// Sums the complete lines in a chunk, reporting how many bytes were consumed. +/// +/// A trailing partial line is left unconsumed so the next read picks it up +/// whole. This is what makes the running total monotonic: every byte is +/// counted exactly once, and a file that is still being appended to never +/// double-counts or loses its last record. +pub(crate) fn consume_chunk(chunk: &str) -> (RecordTally, usize) { + let mut tally = RecordTally::default(); + let mut consumed = 0; + for line in chunk.split_inclusive('\n') { + if !line.ends_with('\n') { + break; // A partial record. Leave it for the next read. + } + consumed += line.len(); + if line.len() > MAX_RECORD_BYTES { + continue; + } + if let Some(record) = parse_record(line.trim_end()) { + tally.tokens.add(record.tokens); + if record.model.is_some() { + tally.model = record.model; + } + } + } + (tally, consumed) +} + +#[cfg(test)] +mod tests { + use super::{TokenTally, consume_chunk, parse_record}; + + /// Shaped exactly like a real assistant record, with the content fields a + /// real one carries so the test proves they are ignored rather than absent. + /// + /// Deliberately one line: a transcript is one JSON object per line, and a + /// fixture that wrapped would not exercise the chunk boundary logic. + const ASSISTANT: &str = concat!( + r#"{"type":"assistant","uuid":"u1","sessionId":"s1","cwd":"/home/x","#, + r#""message":{"id":"msg_1","role":"assistant","model":"claude-opus-5","#, + r#""content":[{"type":"text","text":"SECRET CONVERSATION CONTENT"}],"#, + r#""usage":{"input_tokens":10,"output_tokens":200,"#, + r#""cache_creation_input_tokens":1000,"cache_read_input_tokens":50000,"#, + r#""service_tier":"standard","cache_creation":{"ephemeral_5m_input_tokens":7}}}}"#, + ); + + #[test] + fn an_assistant_record_yields_its_four_counters() { + let record = parse_record(ASSISTANT).expect("an assistant record with usage counts"); + assert_eq!( + record.tokens, + TokenTally { + input: 10, + output: 200, + cache_creation: 1_000, + cache_read: 50_000, + } + ); + assert_eq!(record.model.as_deref(), Some("claude-opus-5")); + assert_eq!(record.tokens.total(), 51_210); + } + + #[test] + fn conversation_content_is_not_representable() { + // The record above carries message text. The parser's own output type + // has nowhere to put it, so this is a property of the types rather + // than of the parsing. + let record = parse_record(ASSISTANT).expect("parsed"); + let rendered = format!("{record:?}"); + assert!( + !rendered.contains("SECRET"), + "no debug rendering of a parsed record may include message content" + ); + } + + #[test] + fn non_assistant_records_contribute_nothing() { + for line in [ + r#"{"type":"user","message":{"role":"user","content":"hello"}}"#, + r#"{"type":"attachment","attachment":{"content":"a file"}}"#, + r#"{"type":"system","subtype":"hook"}"#, + ] { + assert!(parse_record(line).is_none(), "{line} must not be counted"); + } + } + + #[test] + fn an_assistant_record_without_usage_contributes_nothing() { + assert!( + parse_record(r#"{"type":"assistant","message":{"model":"claude-opus-5"}}"#).is_none() + ); + } + + #[test] + fn malformed_lines_are_skipped_rather_than_faulting() { + for line in ["", "not json", "{", "[]", "\"a string\""] { + assert!(parse_record(line).is_none(), "{line:?} must not panic"); + } + } + + #[test] + fn a_partial_trailing_line_is_left_for_the_next_read() { + let chunk = format!("{ASSISTANT}\n{{\"type\":\"assis"); + let (tally, consumed) = consume_chunk(&chunk); + assert_eq!(tally.tokens.total(), 51_210); + assert_eq!( + consumed, + ASSISTANT.len() + 1, + "only the complete record may be consumed" + ); + } + + #[test] + fn resuming_from_the_offset_counts_every_byte_exactly_once() { + let whole = format!("{ASSISTANT}\n{ASSISTANT}\n"); + let (first, consumed) = consume_chunk(&whole[..ASSISTANT.len() + 4]); + let (second, _) = consume_chunk(&whole[consumed..]); + let mut total = first.tokens; + total.add(second.tokens); + assert_eq!( + total.total(), + 2 * 51_210, + "a split read must not double-count or drop a record" + ); + } + + #[test] + fn an_oversized_line_is_consumed_but_not_parsed() { + let line = format!("{}\n", "x".repeat(super::MAX_RECORD_BYTES + 1)); + let (tally, consumed) = consume_chunk(&line); + assert_eq!(tally.tokens.total(), 0); + assert_eq!( + consumed, + line.len(), + "the offset must still advance past it" + ); + } + + #[test] + fn totals_saturate_rather_than_overflowing() { + let tally = TokenTally { + input: u64::MAX, + output: u64::MAX, + cache_creation: 0, + cache_read: 0, + }; + assert_eq!(tally.total(), u64::MAX); + } +} diff --git a/crates/lumbridge-harness/src/clock.rs b/crates/lumbridge-harness/src/clock.rs new file mode 100644 index 0000000..d8d2b68 --- /dev/null +++ b/crates/lumbridge-harness/src/clock.rs @@ -0,0 +1,92 @@ +//! The only place a wall clock enters the usage path. +//! +//! [`lumbridge_core::UsageLedger::record`] rejects an observation older than +//! the newest one already recorded for that profile, because an append-only +//! stream cannot move backwards. A raw `SystemTime::now()` can move backwards: +//! an NTP step, a suspend/resume, or a manual clock change would poison a +//! profile's stream permanently. This clock anchors wall time once and then +//! advances it with a monotonic instant, so the timestamps it emits are +//! non-decreasing by construction while still being comparable to the Unix +//! reset times a provider reports. + +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +/// A non-decreasing Unix-millisecond clock. +#[derive(Debug)] +pub struct MonotonicWallClock { + anchor_unix_ms: u64, + anchor: Instant, + last_emitted_ms: u64, +} + +impl Default for MonotonicWallClock { + fn default() -> Self { + Self::start() + } +} + +impl MonotonicWallClock { + /// Anchors to the system clock once, now. + #[must_use] + pub fn start() -> Self { + let anchor_unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|elapsed| u64::try_from(elapsed.as_millis()).ok()) + .unwrap_or(0); + Self::start_at(anchor_unix_ms, Instant::now()) + } + + /// Anchors to a caller-supplied wall time. Exists so tests can drive the + /// clock without waiting for real time to pass. + #[must_use] + pub(crate) const fn start_at(anchor_unix_ms: u64, anchor: Instant) -> Self { + Self { + anchor_unix_ms, + anchor, + last_emitted_ms: anchor_unix_ms, + } + } + + /// The current time in Unix milliseconds, never earlier than the last value + /// this clock returned. + pub fn now_ms(&mut self) -> u64 { + let elapsed_ms = u64::try_from(self.anchor.elapsed().as_millis()).unwrap_or(u64::MAX); + let candidate = self.anchor_unix_ms.saturating_add(elapsed_ms); + self.last_emitted_ms = self.last_emitted_ms.max(candidate); + self.last_emitted_ms + } + + #[must_use] + pub const fn last_emitted_ms(&self) -> u64 { + self.last_emitted_ms + } +} + +#[cfg(test)] +mod tests { + use super::MonotonicWallClock; + use std::time::Instant; + + #[test] + fn the_clock_starts_at_its_anchor() { + let mut clock = MonotonicWallClock::start_at(1_700_000_000_000, Instant::now()); + assert!(clock.now_ms() >= 1_700_000_000_000); + } + + #[test] + fn emitted_times_never_move_backwards() { + let mut clock = MonotonicWallClock::start(); + let first = clock.now_ms(); + let second = clock.now_ms(); + assert!(second >= first); + assert_eq!(clock.last_emitted_ms(), second); + } + + #[test] + fn a_zero_anchor_still_produces_a_usable_stream() { + let mut clock = MonotonicWallClock::start_at(0, Instant::now()); + let first = clock.now_ms(); + assert!(clock.now_ms() >= first); + } +} diff --git a/crates/lumbridge-harness/src/codex/mod.rs b/crates/lumbridge-harness/src/codex/mod.rs new file mode 100644 index 0000000..46f52ab --- /dev/null +++ b/crates/lumbridge-harness/src/codex/mod.rs @@ -0,0 +1,500 @@ +//! The first real Lumbridge usage adapter: Codex `account/rateLimits/read`. +//! +//! Codex is the first harness because its quota surface is prose-documented +//! upstream with a worked example, pinned by a checked-in JSON Schema, and +//! genuinely provider-originated: the app-server forwards the backend's +//! `x-codex-*-used-percent` response headers rather than computing a number. +//! Lumbridge obtains it by launching `codex app-server` and letting the harness +//! resolve its own credentials from `CODEX_HOME`. Lumbridge never reads an auth +//! file, and [`crate::jsonrpc`] makes asking for a token unrepresentable. +//! +//! Three threads, each with one job. The caller's thread starts the child, so a +//! missing harness is an immediate error rather than a silent fault. A reader +//! thread does the blocking pipe reads. A worker thread owns the protocol clock +//! and the writer. +//! +//! Shutdown is bounded and never waits on the far end. It kills the child's +//! whole process group, sets a stop flag, and joins only the worker, which +//! checks that flag every tick. The reader is deliberately never joined: it is +//! blocked in a pipe read, and while killing the group is what normally closes +//! that pipe, a descendant that escaped the group by calling `setsid` would +//! otherwise hold shutdown open forever. A leaked thread parked on a dead file +//! descriptor is a far better failure than a hung UI, since `Drop` calls +//! shutdown. + +mod parse; +mod session; + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TryRecvError, TrySendError}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use lumbridge_core::{AccountProfile, UsageObservation}; + +use crate::HarnessError; +use crate::child::{ProbeChild, ProbeKiller, ProbeReader, ProbeWriter}; +use crate::clock::MonotonicWallClock; +use crate::jsonrpc::ServerRequestClass; +use crate::probe::{ProbeHealth, ProbeOutcome, UsageProbe}; + +use session::{CodexSession, SessionStep}; + +/// The shortest interval a probe may poll an account quota at. A quota window +/// is measured in minutes; polling faster only burns the user's process table. +const MIN_POLL_INTERVAL: Duration = Duration::from_secs(1); +/// How long the worker waits for a line before re-checking its poll timer. +const WORKER_TICK: Duration = Duration::from_millis(100); +const EVENT_QUEUE: usize = 64; +const LINE_QUEUE: usize = 64; + +/// How the probe is launched. +#[derive(Clone, Debug)] +pub struct CodexProbeOptions { + /// The program to run. Defaults to `codex` on `PATH`. + pub program: String, + /// How often to request a fresh reading. + pub poll_interval: Duration, + /// Reported to the harness during the handshake. Not a credential. + pub client_name: String, + pub client_version: String, +} + +impl Default for CodexProbeOptions { + fn default() -> Self { + Self { + program: "codex".to_owned(), + poll_interval: Duration::from_secs(60), + client_name: "lumbridge".to_owned(), + client_version: env!("CARGO_PKG_VERSION").to_owned(), + } + } +} + +/// Replays a synthetic app-server transcript through the real session logic. +/// +/// This is the same state machine the live probe runs, with the process, the +/// socket, and the clock removed: each frame carries the moment it is to be +/// treated as observed. It exists so an adapter can be verified end to end +/// against a fixture, and so a captured (synthetic) transcript can be checked +/// offline when upstream changes its wire shape. +/// +/// Frames the session would answer are answered internally and dropped; only +/// the observations it decided to record come back. +#[must_use] +pub fn replay_transcript(frames: &[(&str, u64)]) -> Vec { + let Ok(mut session) = CodexSession::new() else { + return Vec::new(); + }; + let _ = session.initialize("lumbridge-replay", "0"); + let mut observations = Vec::new(); + for (line, observed_at_ms) in frames { + // A replay polls whenever the session will let it, so a transcript + // does not have to encode the worker's timer. + let _ = session.poll_frame(); + match session.on_line(line, *observed_at_ms) { + SessionStep::Observations(mut batch) => observations.append(&mut batch), + SessionStep::Idle + | SessionStep::Send(_) + | SessionStep::Health(_) + | SessionStep::Refused { .. } => {} + } + } + observations +} + +/// What the worker needs to run the handshake and its poll timer. +struct WorkerSetup { + client_name: String, + client_version: String, + poll_interval_ms: u64, +} + +#[derive(Debug)] +enum WorkerEvent { + Observations(Vec), + Health(ProbeHealth), + RefusedServerRequest(ServerRequestClass), +} + +/// A running Codex quota probe. +pub struct CodexProbe { + profiles: Vec, + events: Option>, + killer: ProbeKiller, + stop: Arc, + worker: Option>, + health: ProbeHealth, + refused_credential_requests: u64, +} + +impl CodexProbe { + /// Starts the probe, its reader, and its worker. + /// + /// # Errors + /// + /// Returns [`HarnessError::ProbeIntervalTooShort`] below one second, + /// [`HarnessError::EmptyProgram`] for a blank program name, and + /// [`HarnessError::SpawnFailed`] when the harness cannot be launched. + pub fn start(options: CodexProbeOptions) -> Result { + if options.poll_interval < MIN_POLL_INTERVAL { + return Err(HarnessError::ProbeIntervalTooShort); + } + let CodexProbeOptions { + program, + poll_interval, + client_name, + client_version, + } = options; + + let session = CodexSession::new()?; + let profiles = session.profiles().to_vec(); + let (writer, reader, killer) = ProbeChild::spawn(&program, &["app-server"])?.into_parts(); + + let (line_sender, lines) = mpsc::sync_channel(LINE_QUEUE); + let (event_sender, events) = mpsc::sync_channel(EVENT_QUEUE); + let stop = Arc::new(AtomicBool::new(false)); + + // Deliberately not retained: the reader is never joined. See the module + // documentation for why waiting on a blocking pipe read is unsafe here. + thread::Builder::new() + .name("lumbridge-codex-probe-reader".to_owned()) + .spawn(move || run_reader(reader, &line_sender)) + .map_err(|error| HarnessError::SpawnFailed(error.kind()))?; + + let setup = WorkerSetup { + client_name, + client_version, + poll_interval_ms: u64::try_from(poll_interval.as_millis()).unwrap_or(u64::MAX), + }; + let worker_stop = Arc::clone(&stop); + let worker = thread::Builder::new() + .name("lumbridge-codex-probe".to_owned()) + .spawn(move || { + run_worker(session, &setup, writer, &lines, &event_sender, &worker_stop); + }) + .map_err(|error| HarnessError::SpawnFailed(error.kind()))?; + + Ok(Self { + profiles, + events: Some(events), + killer, + stop, + worker: Some(worker), + health: ProbeHealth::Starting, + refused_credential_requests: 0, + }) + } + + /// How many times the harness asked this probe for a credential and was + /// refused. A non-zero count is not an error; it is evidence that the + /// refusal path ran. + #[must_use] + pub const fn refused_credential_requests(&self) -> u64 { + self.refused_credential_requests + } +} + +impl UsageProbe for CodexProbe { + fn poll(&mut self) -> ProbeOutcome { + let Some(events) = self.events.as_ref() else { + return ProbeOutcome::idle(self.health); + }; + let mut observations = Vec::new(); + loop { + match events.try_recv() { + Ok(WorkerEvent::Observations(mut batch)) => observations.append(&mut batch), + Ok(WorkerEvent::Health(health)) => self.health = health, + Ok(WorkerEvent::RefusedServerRequest(class)) => { + if class == ServerRequestClass::CredentialRefresh { + self.refused_credential_requests = + self.refused_credential_requests.saturating_add(1); + } + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + if !self.health.is_faulted() { + self.health = ProbeHealth::Stopped; + } + self.events = None; + break; + } + } + } + ProbeOutcome::new(observations, self.health) + } + + fn profiles(&self) -> &[AccountProfile] { + &self.profiles + } + + fn shutdown(&mut self) { + // Order matters. Signal first so the worker cannot start another poll, + // then kill the whole group, then join only the worker: it wakes at + // most one tick later regardless of what the child is doing. + self.stop.store(true, Ordering::Relaxed); + self.killer.kill(); + self.events = None; + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + if !self.health.is_faulted() { + self.health = ProbeHealth::Stopped; + } + } +} + +impl Drop for CodexProbe { + fn drop(&mut self) { + self.shutdown(); + } +} + +/// Blocking reads, and nothing else. +fn run_reader(mut reader: ProbeReader, lines: &SyncSender>) { + loop { + let outcome = reader.read_line(); + let terminal = outcome.is_err(); + if lines.send(outcome).is_err() || terminal { + break; + } + } +} + +/// Owns the protocol clock and the writer. Holds no policy of its own. +fn run_worker( + mut session: CodexSession, + setup: &WorkerSetup, + mut writer: ProbeWriter, + lines: &Receiver>, + events: &SyncSender, + stop: &AtomicBool, +) { + let mut clock = MonotonicWallClock::start(); + let handshake = session.initialize(&setup.client_name, &setup.client_version); + if let Err(error) = writer.write_line(&handshake) { + let _ = events.try_send(WorkerEvent::Health(ProbeHealth::Faulted(error))); + return; + } + + // Zero means "poll as soon as the handshake lets us", because + // `poll_frame` returns nothing until the session is ready. + let mut last_poll_ms = 0; + loop { + if stop.load(Ordering::Relaxed) { + break; + } + let now_ms = clock.now_ms(); + if now_ms.saturating_sub(last_poll_ms) >= setup.poll_interval_ms + && let Some(frame) = session.poll_frame() + { + last_poll_ms = now_ms; + if writer.write_line(&frame).is_err() { + let _ = + events.try_send(WorkerEvent::Health(ProbeHealth::Faulted(HarnessError::Eof))); + break; + } + } + + match lines.recv_timeout(WORKER_TICK) { + Ok(Ok(line)) => { + if !dispatch(&mut session, &mut writer, events, &line, clock.now_ms()) { + break; + } + } + Ok(Err(error)) => { + let _ = events.try_send(WorkerEvent::Health(ProbeHealth::Faulted(error))); + break; + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + } +} + +/// Applies one line. Returns whether the worker should keep running. +fn dispatch( + session: &mut CodexSession, + writer: &mut ProbeWriter, + events: &SyncSender, + line: &str, + now_ms: u64, +) -> bool { + match session.on_line(line.trim_end(), now_ms) { + SessionStep::Idle => true, + SessionStep::Send(frame) => { + if writer.write_line(&frame).is_err() { + return false; + } + if session.is_ready() { + let _ = events.try_send(WorkerEvent::Health(ProbeHealth::Ready)); + } + true + } + SessionStep::Refused { line, class } => { + let _ = events.try_send(WorkerEvent::RefusedServerRequest(class)); + writer.write_line(&line).is_ok() + } + SessionStep::Observations(observations) => !matches!( + events.try_send(WorkerEvent::Observations(observations)), + Err(TrySendError::Disconnected(_)) + ), + SessionStep::Health(health) => { + let keep_running = !health.is_faulted(); + let _ = events.try_send(WorkerEvent::Health(health)); + keep_running + } + } +} + +#[cfg(test)] +mod tests { + use super::{CodexProbe, CodexProbeOptions}; + use crate::HarnessError; + use crate::probe::{ProbeHealth, UsageProbe}; + use std::time::Duration; + + /// A synthetic launcher that ignores its arguments, backgrounds a helper + /// that inherits stdio, and waits. This is the shape of a wrapper script + /// that execs the real program as a grandchild, which is how the npm + /// distribution of Codex behaves. + fn launcher_script(tag: &str) -> Option { + use std::io::Write; + + let path = std::env::temp_dir().join(format!( + "lumbridge-probe-launcher-{}-{tag}.sh", + std::process::id() + )); + let mut file = std::fs::File::create(&path).ok()?; + file.write_all(b"#!/bin/sh\nsleep 600 &\nwait\n").ok()?; + drop(file); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).ok()?; + } + Some(path) + } + + fn options(program: &str) -> CodexProbeOptions { + CodexProbeOptions { + program: program.to_owned(), + poll_interval: Duration::from_secs(1), + ..CodexProbeOptions::default() + } + } + + #[test] + fn a_too_frequent_poll_interval_is_refused() { + let options = CodexProbeOptions { + poll_interval: Duration::from_millis(10), + ..CodexProbeOptions::default() + }; + assert_eq!( + CodexProbe::start(options).err(), + Some(HarnessError::ProbeIntervalTooShort) + ); + } + + #[test] + fn an_empty_program_is_refused() { + assert_eq!( + CodexProbe::start(options(" ")).err(), + Some(HarnessError::EmptyProgram) + ); + } + + #[test] + fn a_missing_harness_fails_at_start_rather_than_silently() { + assert!(matches!( + CodexProbe::start(options("lumbridge-no-such-harness")).err(), + Some(HarnessError::SpawnFailed(_)) + )); + } + + #[test] + fn a_silent_harness_can_still_be_shut_down() { + let Some(script) = launcher_script("silent") else { + return; + }; + let Some(program) = script.to_str() else { + return; + }; + let Ok(mut probe) = CodexProbe::start(options(program)) else { + let _ = std::fs::remove_file(&script); + return; + }; + assert_eq!(probe.profiles().len(), 2); + assert_eq!(probe.poll().health(), ProbeHealth::Starting); + probe.shutdown(); + assert!(matches!( + probe.poll().health(), + ProbeHealth::Stopped | ProbeHealth::Faulted(_) + )); + let _ = std::fs::remove_file(&script); + } + + #[test] + fn shutdown_is_idempotent() { + let Some(script) = launcher_script("idempotent") else { + return; + }; + let Some(program) = script.to_str() else { + return; + }; + let Ok(mut probe) = CodexProbe::start(options(program)) else { + let _ = std::fs::remove_file(&script); + return; + }; + probe.shutdown(); + probe.shutdown(); + let _ = std::fs::remove_file(&script); + } + + #[test] + fn shutdown_returns_promptly_behind_a_launcher_that_never_speaks() { + // The grandchild inherits our stdout and never writes, so the reader is + // blocked in a pipe read that will never complete on its own. Before + // the process-group kill and the bounded join, shutdown blocked here + // until the orphan exited — ten minutes, in this fixture. + let Some(script) = launcher_script("prompt") else { + return; + }; + let Some(program) = script.to_str() else { + return; + }; + let Ok(mut probe) = CodexProbe::start(options(program)) else { + let _ = std::fs::remove_file(&script); + return; + }; + let started = std::time::Instant::now(); + probe.shutdown(); + let elapsed = started.elapsed(); + let _ = std::fs::remove_file(&script); + assert!( + elapsed < Duration::from_secs(5), + "shutdown must not wait on the far end, took {elapsed:?}" + ); + } + + #[test] + fn a_harness_that_exits_immediately_faults_the_probe() { + let Ok(mut probe) = CodexProbe::start(options("true")) else { + return; + }; + let mut health = probe.poll().health(); + for _ in 0..200 { + if health != ProbeHealth::Starting { + break; + } + std::thread::sleep(Duration::from_millis(10)); + health = probe.poll().health(); + } + assert!( + matches!(health, ProbeHealth::Faulted(_)), + "a harness that closes its output must surface as a fault, got {health:?}" + ); + probe.shutdown(); + } +} diff --git a/crates/lumbridge-harness/src/codex/parse.rs b/crates/lumbridge-harness/src/codex/parse.rs new file mode 100644 index 0000000..7d0a823 --- /dev/null +++ b/crates/lumbridge-harness/src/codex/parse.rs @@ -0,0 +1,397 @@ +//! The Codex rate-limit parser: hostile input in, a closed decision out. +//! +//! Every skew, roll, overflow, and out-of-range case is decided here, by pure +//! functions over deserialized wire values with no process, no clock, and no +//! network. That is what makes the honesty rules testable. +//! +//! Wire shape adapted from the openai/codex app-server protocol (Apache-2.0), +//! commit `17e8101699c5062117d0d37f504313e8af53b043`: +//! `codex-rs/app-server/README.md` section "7) Rate limits (`ChatGPT`)" and the +//! checked-in schema `codex_app_server_protocol.v2.schemas.json`, where +//! `RateLimitWindow` requires only `usedPercent` and both +//! `windowDurationMins` and `resetsAt` are nullable. No upstream code is +//! copied; only the observable wire contract is mirrored. + +use lumbridge_core::{AccountProfileId, UsageObservation, UsageProvenance, UsageUnit, UsageWindow}; +use serde::Deserialize; + +/// Percent is an integer 0..=100 on the wire, so the honest resolution of a +/// Codex reading is one percent even though the ledger stores permille. +const MAX_USED_PERCENT: i64 = 100; +const PERMILLE_PER_PERCENT: u64 = 10; +const MILLIS_PER_SECOND: i64 = 1_000; +const MILLIS_PER_MINUTE: i64 = 60_000; +/// A permille ceiling makes the window fraction a bounded quantity, which is +/// what lets the ledger derive an exhaustion estimate. It is not an invented +/// quota: a percentage is by definition a share of one whole. +const PERMILLE_CEILING: u64 = 1_000; + +/// One quota window exactly as Codex sends it. +/// +/// Only the three documented fields are deserialized. Sibling fields such as +/// `rateLimitResetCredits`, `individualLimit`, `accountId`, and +/// `rateLimitUpsell` are deliberately not modelled: a probe should not be able +/// to accidentally read account metadata it has no use for. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RateLimitWindowWire { + /// Widened to `i64` so an out-of-range wire value reaches our own check + /// rather than failing inside serde with a less specific error. + pub(crate) used_percent: i64, + #[serde(default)] + pub(crate) window_duration_mins: Option, + #[serde(default)] + pub(crate) resets_at: Option, +} + +/// The snapshot. Every field is optional in the upstream schema, including +/// `primary`, so absence must be a first-class case rather than a parse error. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RateLimitSnapshotWire { + #[serde(default)] + pub(crate) primary: Option, + #[serde(default)] + pub(crate) secondary: Option, +} + +/// The `account/rateLimits/read` result body, which is also the shape of the +/// `account/rateLimits/updated` notification's params. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RateLimitsResultWire { + #[serde(default)] + pub(crate) rate_limits: RateLimitSnapshotWire, +} + +/// Why a syntactically valid window still cannot be recorded as a fact. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UnusableReason { + /// A percentage outside 0..=100. The relay is not telling the truth. + PercentOutOfRange, + /// The window had already ended when the reading was taken. Decision 0012 + /// requires an expired window to read as rolled over rather than as a + /// frozen percentage from a window that is gone. + WindowEnded, + /// A reset timestamp that cannot be represented as Unix milliseconds. + ResetsAtOutOfRange, +} + +/// What the parser decided about one window. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WindowReading { + /// A usable share of the window, with a window attached when the wire + /// carried enough to build one. + Usable { + permille: u64, + window: Option, + }, + /// The reading must be recorded as an explicit gap. + Unusable(UnusableReason), +} + +/// Decides what a single Codex window means at a point in time. +/// +/// Total: every input produces a decision, and none of them panics. +pub(crate) fn parse_window(wire: &RateLimitWindowWire, observed_at_ms: u64) -> WindowReading { + if wire.used_percent < 0 || wire.used_percent > MAX_USED_PERCENT { + return WindowReading::Unusable(UnusableReason::PercentOutOfRange); + } + // Lossless: used_percent is already proven to be 0..=100. + let permille = u64::try_from(wire.used_percent).unwrap_or(0) * PERMILLE_PER_PERCENT; + + let Some(resets_at_seconds) = wire.resets_at else { + // A percentage with no reset time is still a true percentage. It just + // cannot answer "when does this refill?". + return WindowReading::Usable { + permille, + window: None, + }; + }; + let Some(resets_at_ms) = unix_seconds_to_millis(resets_at_seconds) else { + return WindowReading::Unusable(UnusableReason::ResetsAtOutOfRange); + }; + if observed_at_ms >= resets_at_ms { + return WindowReading::Unusable(UnusableReason::WindowEnded); + } + + // The reset is a fact on its own. Codex declares the duration and the reset + // independently, so a missing or unreconcilable start must cost the start + // and nothing else: the reset still bounds the exhaustion forecast and + // still identifies which quota period this reading belongs to. + let window = wire + .window_duration_mins + .and_then(|minutes| window_start_ms(resets_at_ms, minutes)) + // A start after the observation means the local clock disagrees with + // the provider's. That disagreement is about the start, not the reset. + .filter(|started_at_ms| *started_at_ms <= observed_at_ms) + .and_then(|started_at_ms| UsageWindow::new(started_at_ms, resets_at_ms).ok()) + .unwrap_or_else(|| UsageWindow::until(resets_at_ms)); + + WindowReading::Usable { + permille, + window: Some(window), + } +} + +fn unix_seconds_to_millis(seconds: i64) -> Option { + let millis = seconds.checked_mul(MILLIS_PER_SECOND)?; + u64::try_from(millis).ok() +} + +fn window_start_ms(resets_at_ms: u64, duration_minutes: i64) -> Option { + if duration_minutes <= 0 { + return None; + } + let duration_ms = u64::try_from(duration_minutes.checked_mul(MILLIS_PER_MINUTE)?).ok()?; + resets_at_ms.checked_sub(duration_ms) +} + +/// Turns a decision into the observation the ledger should hold. +/// +/// A usable reading is `ProviderReported`: Codex forwards the backend's +/// `x-codex-*-used-percent` response headers rather than computing the value, +/// so the provider is the one making the claim. Anything else is an explicit +/// gap, never a zero. +pub(crate) fn to_observation( + profile: &AccountProfileId, + reading: WindowReading, + observed_at_ms: u64, +) -> UsageObservation { + let WindowReading::Usable { permille, window } = reading else { + return UsageObservation::unavailable(profile.clone(), observed_at_ms); + }; + let Ok(observation) = UsageObservation::counted( + profile.clone(), + UsageUnit::WindowPermille, + permille, + UsageProvenance::ProviderReported, + observed_at_ms, + ) else { + return UsageObservation::unavailable(profile.clone(), observed_at_ms); + }; + let observation = observation + .with_limit(PERMILLE_CEILING) + .unwrap_or_else(|_| UsageObservation::unavailable(profile.clone(), observed_at_ms)); + let Some(window) = window else { + return observation; + }; + // A window that no longer contains the observation is rejected by core. + // Keeping the windowless observation preserves the provider's percentage + // instead of discarding a true fact over a timing disagreement. + observation + .clone() + .with_window(window) + .unwrap_or(observation) +} + +#[cfg(test)] +mod tests { + use super::{ + RateLimitSnapshotWire, RateLimitWindowWire, RateLimitsResultWire, UnusableReason, + WindowReading, parse_window, to_observation, + }; + use lumbridge_core::{AccountProfileId, UsageProvenance, UsageUnit, UsageWindow}; + + const HOUR_MS: u64 = 3_600_000; + const RESETS_AT_SECONDS: i64 = 1_730_947_200; + const RESETS_AT_MS: u64 = 1_730_947_200_000; + + fn profile() -> AccountProfileId { + AccountProfileId::new("codex-app-server-primary").expect("a valid fixture ID") + } + + fn wire( + used_percent: i64, + minutes: Option, + resets_at: Option, + ) -> RateLimitWindowWire { + RateLimitWindowWire { + used_percent, + window_duration_mins: minutes, + resets_at, + } + } + + #[test] + fn the_documented_example_parses_to_a_bounded_window() { + // The worked example from the upstream README, read one hour before it + // resets: { "usedPercent": 25, "windowDurationMins": 15, ... } widened + // to a duration long enough to contain the observation. + let reading = parse_window( + &wire(25, Some(300), Some(RESETS_AT_SECONDS)), + RESETS_AT_MS - HOUR_MS, + ); + let WindowReading::Usable { permille, window } = reading else { + panic!("the documented example must be usable"); + }; + assert_eq!(permille, 250); + let window = window.expect("a duration and a reset build a window"); + assert_eq!(window.resets_at_ms(), RESETS_AT_MS); + assert_eq!(window.started_at_ms(), Some(RESETS_AT_MS - 300 * 60_000)); + } + + #[test] + fn a_reading_taken_after_the_reset_is_a_rolled_window_not_a_frozen_percentage() { + let reading = parse_window( + &wire(93, Some(300), Some(RESETS_AT_SECONDS)), + RESETS_AT_MS + 1, + ); + assert_eq!( + reading, + WindowReading::Unusable(UnusableReason::WindowEnded) + ); + let observation = to_observation(&profile(), reading, RESETS_AT_MS + 1); + assert_eq!(observation.provenance(), UsageProvenance::Unavailable); + assert_eq!(observation.consumed(), 0); + } + + #[test] + fn an_absent_duration_keeps_the_percentage_and_the_reset() { + // Codex declares the duration and the reset independently. Dropping + // the reset for want of a duration would discard a fact the provider + // stated, and would let a burn baseline be drawn across two quota + // periods while the exhaustion guard never fired. + let reading = parse_window( + &wire(40, None, Some(RESETS_AT_SECONDS)), + RESETS_AT_MS - HOUR_MS, + ); + assert_eq!( + reading, + WindowReading::Usable { + permille: 400, + window: Some(UsageWindow::until(RESETS_AT_MS)) + } + ); + } + + #[test] + fn an_absent_reset_keeps_the_percentage_and_drops_the_window() { + let reading = parse_window(&wire(40, Some(300), None), 5_000); + assert_eq!( + reading, + WindowReading::Usable { + permille: 400, + window: None + } + ); + } + + #[test] + fn a_start_after_the_observation_costs_the_start_and_not_the_reset() { + // A 15 minute window resetting far in the future starts after now: + // the local clock and the provider disagree about the start only. + let reading = parse_window(&wire(25, Some(15), Some(RESETS_AT_SECONDS)), 1_000); + assert_eq!( + reading, + WindowReading::Usable { + permille: 250, + window: Some(UsageWindow::until(RESETS_AT_MS)) + }, + "clock disagreement must not discard the provider's reset time" + ); + } + + #[test] + fn an_out_of_range_percentage_is_refused() { + for percent in [-1, 101, i64::MAX, i64::MIN] { + assert_eq!( + parse_window(&wire(percent, Some(300), Some(RESETS_AT_SECONDS)), 0), + WindowReading::Unusable(UnusableReason::PercentOutOfRange), + "percent {percent} must not be recorded" + ); + } + } + + #[test] + fn an_unrepresentable_reset_is_refused_without_overflowing() { + for seconds in [-1, i64::MIN, i64::MAX] { + assert!(matches!( + parse_window(&wire(25, Some(300), Some(seconds)), 0), + WindowReading::Unusable(_) + )); + } + } + + #[test] + fn a_zero_or_negative_duration_does_not_underflow() { + for minutes in [0, -5, i64::MIN] { + let reading = parse_window(&wire(25, Some(minutes), Some(RESETS_AT_SECONDS)), 1_000); + assert_eq!( + reading, + WindowReading::Usable { + permille: 250, + window: Some(UsageWindow::until(RESETS_AT_MS)) + }, + "a nonsensical duration must not cost the reset" + ); + } + } + + #[test] + fn a_weekly_window_matches_the_shape_a_live_account_returns() { + // Observed from a real account: usedPercent 18, windowDurationMins + // 10080 (one week), resetsAt in Unix seconds, secondary null. + let week_minutes: i64 = 10_080; + let observed_at_ms = RESETS_AT_MS - 24 * HOUR_MS; + let reading = parse_window( + &wire(18, Some(week_minutes), Some(RESETS_AT_SECONDS)), + observed_at_ms, + ); + let WindowReading::Usable { permille, window } = reading else { + panic!("a weekly window is usable"); + }; + assert_eq!(permille, 180); + let window = window.expect("a week-long window is still a window"); + assert_eq!( + window.started_at_ms(), + Some(RESETS_AT_MS - u64::try_from(week_minutes).expect("a positive duration") * 60_000) + ); + } + + #[test] + fn a_usable_reading_becomes_a_provider_reported_permille_observation() { + let reading = parse_window( + &wire(25, Some(300), Some(RESETS_AT_SECONDS)), + RESETS_AT_MS - HOUR_MS, + ); + let observation = to_observation(&profile(), reading, RESETS_AT_MS - HOUR_MS); + assert_eq!(observation.provenance(), UsageProvenance::ProviderReported); + assert_eq!(observation.unit(), Some(UsageUnit::WindowPermille)); + assert_eq!(observation.consumed(), 250); + assert_eq!(observation.limit(), Some(1_000)); + assert!(observation.window().is_some()); + } + + #[test] + fn an_absent_primary_window_deserializes_rather_than_failing() { + let body: RateLimitsResultWire = serde_json::from_str( + r#"{"rateLimits":{"primary":null,"secondary":null,"rateLimitReachedType":null}}"#, + ) + .expect("every snapshot field is optional upstream"); + assert!(body.rate_limits.primary.is_none()); + assert!(body.rate_limits.secondary.is_none()); + } + + #[test] + fn unmodelled_sibling_fields_are_ignored_not_rejected() { + // The upstream result also carries rateLimitResetCredits, accountId, + // individualLimit and rateLimitUpsell. A probe must tolerate them and + // must not model them. + let body: RateLimitsResultWire = serde_json::from_str( + r#"{"rateLimits":{"primary":{"usedPercent":25,"windowDurationMins":15, + "resetsAt":1730947200},"individualLimit":null,"accountId":"acct_x", + "planType":"plus"},"rateLimitResetCredits":{"availableCount":2}}"#, + ) + .expect("unknown fields are ignored"); + let primary = body.rate_limits.primary.expect("primary is present"); + assert_eq!(primary.used_percent, 25); + } + + #[test] + fn a_snapshot_with_no_windows_defaults_rather_than_panicking() { + let snapshot = RateLimitSnapshotWire::default(); + assert!(snapshot.primary.is_none()); + assert!(snapshot.secondary.is_none()); + } +} diff --git a/crates/lumbridge-harness/src/codex/session.rs b/crates/lumbridge-harness/src/codex/session.rs new file mode 100644 index 0000000..f75da8a --- /dev/null +++ b/crates/lumbridge-harness/src/codex/session.rs @@ -0,0 +1,584 @@ +//! The Codex probe's protocol logic, as a pure state machine. +//! +//! Every decision the probe makes about what to send, what to record, and what +//! to refuse happens here, driven by `&str` lines and an explicit timestamp. +//! No process, no socket, and no clock are involved, so the whole protocol is +//! testable from string fixtures. The thread in [`super::worker`] does nothing +//! but move bytes between a child process and this type. + +use lumbridge_core::{AccountProfile, AccountProfileId, UsageObservation, UsageWindow}; +use serde_json::{Value, json}; + +use crate::HarnessError; +use crate::jsonrpc::{ + AcceptedNotification, INVALID_REQUEST, Inbound, OutboundRequest, ServerRequestClass, classify, + encode_notification, encode_refusal, encode_request, +}; +use crate::probe::ProbeHealth; + +use super::parse::{ + RateLimitSnapshotWire, RateLimitsResultWire, WindowReading, parse_window, to_observation, +}; + +/// Re-emit an unchanged reading at least this often so the ledger keeps a +/// baseline for its burn rate. Without a floor, a quiet account would hold one +/// lone observation and the footer could never derive a rate; without a +/// ceiling on how often we re-record, the bounded retention would be flushed +/// of real history by identical rows. +const MIN_REEMIT_INTERVAL_MS: u64 = 300_000; + +/// The two windows Codex reports are two separate facts about two separate +/// quota periods. Merging them would mean silently choosing one. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WindowSlot { + Primary, + Secondary, +} + +impl WindowSlot { + const ALL: [Self; 2] = [Self::Primary, Self::Secondary]; + + /// Static, account-free identifiers. A profile ID is persisted and must + /// never carry an account identity. + const fn profile_id(self) -> &'static str { + match self { + Self::Primary => "codex-app-server-primary", + Self::Secondary => "codex-app-server-secondary", + } + } + + /// Codex rate limits are account-scoped, not model-scoped. Naming the + /// scope is honest; inventing a model name would not be. + const fn scope(self) -> &'static str { + match self { + Self::Primary => "primary window", + Self::Secondary => "secondary window", + } + } +} + +/// What the session wants the caller to do next. +#[derive(Clone, Debug)] +pub(crate) enum SessionStep { + /// Nothing to do. + Idle, + /// Write this line to the child's stdin. + Send(String), + /// Record these observations. + Observations(Vec), + /// The probe's liveness changed. + Health(ProbeHealth), + /// A server-to-client request was refused. Write the line, and note why. + Refused { + line: String, + class: ServerRequestClass, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Stage { + AwaitingInitialize, + Ready, +} + +/// The last thing recorded for one profile, used to suppress identical rows. +#[derive(Clone, Copy, Debug)] +struct LastRecorded { + permille: u64, + window: Option, + at_ms: u64, +} + +pub(crate) struct CodexSession { + profiles: Vec, + stage: Stage, + next_id: u64, + initialize_id: u64, + pending_read_id: Option, + last: [Option; 2], + refused_credential_requests: u64, +} + +impl CodexSession { + pub(crate) fn new() -> Result { + let profiles = WindowSlot::ALL + .into_iter() + .map(|slot| { + AccountProfile::new( + slot.profile_id(), + "Codex", + "ChatGPT", + slot.scope(), + "subscription", + ) + .map_err(|_| HarnessError::EmptyProgram) + }) + .collect::, _>>()?; + Ok(Self { + profiles, + stage: Stage::AwaitingInitialize, + next_id: 1, + initialize_id: 0, + pending_read_id: None, + last: [None, None], + refused_credential_requests: 0, + }) + } + + pub(crate) fn profiles(&self) -> &[AccountProfile] { + &self.profiles + } + + /// Whether the handshake has completed and readings may be requested. + pub(crate) const fn is_ready(&self) -> bool { + matches!(self.stage, Stage::Ready) + } + + #[cfg(test)] + pub(crate) const fn refused_credential_requests(&self) -> u64 { + self.refused_credential_requests + } + + fn take_id(&mut self) -> u64 { + let id = self.next_id; + self.next_id = self.next_id.saturating_add(1); + id + } + + /// The handshake frame. Codex requires `initialize` before any other call. + pub(crate) fn initialize(&mut self, client_name: &str, client_version: &str) -> String { + let id = self.take_id(); + self.initialize_id = id; + encode_request( + id, + OutboundRequest::Initialize, + &json!({ "clientInfo": { "name": client_name, "version": client_version } }), + ) + } + + /// Requests a fresh reading, unless one is already outstanding. + pub(crate) fn poll_frame(&mut self) -> Option { + if self.stage != Stage::Ready || self.pending_read_id.is_some() { + return None; + } + let id = self.take_id(); + self.pending_read_id = Some(id); + Some(encode_request( + id, + OutboundRequest::ReadAccountRateLimits, + &json!({}), + )) + } + + /// Consumes one line from the child. + pub(crate) fn on_line(&mut self, line: &str, observed_at_ms: u64) -> SessionStep { + let Some(inbound) = classify(line) else { + return SessionStep::Health(ProbeHealth::Faulted(HarnessError::Malformed)); + }; + match inbound { + Inbound::ServerRequest { id, class } => { + if class == ServerRequestClass::CredentialRefresh { + self.refused_credential_requests = + self.refused_credential_requests.saturating_add(1); + } + SessionStep::Refused { + line: encode_refusal(&id), + class, + } + } + Inbound::Result { id, result } => self.on_result(id, &result, observed_at_ms), + Inbound::Failure { id, code } => self.on_failure(id, code, observed_at_ms), + // Matching the kind exhaustively means adding a second accepted + // notification cannot silently reuse the rate-limit path. + Inbound::Notification { + kind: AcceptedNotification::AccountRateLimitsUpdated, + params, + } => self.on_snapshot_body(¶ms, observed_at_ms, true), + Inbound::Ignored => SessionStep::Idle, + } + } + + fn on_result(&mut self, id: u64, result: &Value, observed_at_ms: u64) -> SessionStep { + if self.stage == Stage::AwaitingInitialize && id == self.initialize_id { + self.stage = Stage::Ready; + return SessionStep::Send(encode_notification("initialized", &json!({}))); + } + if self.pending_read_id == Some(id) { + self.pending_read_id = None; + return self.on_snapshot_body(result, observed_at_ms, false); + } + SessionStep::Idle + } + + fn on_failure(&mut self, id: u64, code: i64, observed_at_ms: u64) -> SessionStep { + if self.pending_read_id == Some(id) { + self.pending_read_id = None; + // Codex answers -32600 when the account cannot report a quota, + // which is a true statement about the account rather than a fault. + if code == INVALID_REQUEST { + let observations = self.unavailable_for_all(observed_at_ms); + self.forget_all(); + return SessionStep::Observations(observations); + } + return SessionStep::Health(ProbeHealth::Faulted(HarnessError::Rejected(code))); + } + if id == self.initialize_id { + return SessionStep::Health(ProbeHealth::Faulted(HarnessError::Rejected(code))); + } + SessionStep::Idle + } + + /// Turns a `rateLimits` value into observations. + /// + /// `sparse` marks a push notification. Upstream documents that a rolling + /// update may omit values and that omission "does not clear a previously + /// observed value", so an absent window on a push records nothing at all. + /// It must not record an explicit gap, and it must not be merged into the + /// last full read either: a merged composite would look provider-reported + /// while being partly a memory. + fn on_snapshot_body(&mut self, body: &Value, observed_at_ms: u64, sparse: bool) -> SessionStep { + let Ok(body) = serde_json::from_value::(body.clone()) else { + return SessionStep::Health(ProbeHealth::Faulted(HarnessError::Malformed)); + }; + let observations = self.observations_from(&body.rate_limits, observed_at_ms, sparse); + if observations.is_empty() { + SessionStep::Idle + } else { + SessionStep::Observations(observations) + } + } + + fn observations_from( + &mut self, + snapshot: &RateLimitSnapshotWire, + observed_at_ms: u64, + sparse: bool, + ) -> Vec { + let mut observations = Vec::new(); + for slot in WindowSlot::ALL { + let index = slot as usize; + let wire = match slot { + WindowSlot::Primary => snapshot.primary.as_ref(), + WindowSlot::Secondary => snapshot.secondary.as_ref(), + }; + let Some(wire) = wire else { + if !sparse { + // A full read that omits a window means the window is gone. + if self.last[index].take().is_some() { + observations.push(UsageObservation::unavailable( + self.profile_id(index), + observed_at_ms, + )); + } + } + continue; + }; + let reading = parse_window(wire, observed_at_ms); + if !self.should_record(index, reading, observed_at_ms) { + continue; + } + observations.push(to_observation( + &self.profile_id(index), + reading, + observed_at_ms, + )); + } + observations + } + + fn profile_id(&self, index: usize) -> AccountProfileId { + self.profiles[index].id().clone() + } + + /// Suppresses an identical reading unless the re-emit floor has passed. + fn should_record(&mut self, index: usize, reading: WindowReading, observed_at_ms: u64) -> bool { + let WindowReading::Usable { permille, window } = reading else { + // A gap is only worth recording when it changes the story. + return self.last[index].take().is_some(); + }; + let unchanged = self.last[index].is_some_and(|last| { + last.permille == permille + && last.window == window + && observed_at_ms.saturating_sub(last.at_ms) < MIN_REEMIT_INTERVAL_MS + }); + if unchanged { + return false; + } + self.last[index] = Some(LastRecorded { + permille, + window, + at_ms: observed_at_ms, + }); + true + } + + /// An explicit gap for every profile, stamped with when the gap was + /// observed. + /// + /// Stamping these from the last successful reading — or from the epoch when + /// there has never been one — would put them behind the ledger's newest + /// entry, and an append-only stream rejects a backwards timestamp. The gap + /// would then be silently dropped and a stale percentage would keep + /// rendering as current, which is the exact failure decision 0012 exists to + /// prevent. + fn unavailable_for_all(&self, observed_at_ms: u64) -> Vec { + (0..self.profiles.len()) + .map(|index| UsageObservation::unavailable(self.profile_id(index), observed_at_ms)) + .collect() + } + + fn forget_all(&mut self) { + self.last = [None, None]; + } +} + +#[cfg(test)] +mod tests { + use super::{CodexSession, SessionStep, WindowSlot}; + use crate::jsonrpc::ServerRequestClass; + use crate::probe::ProbeHealth; + use lumbridge_core::{UsageProvenance, UsageUnit}; + + const HOUR_MS: u64 = 3_600_000; + const RESETS_AT_SECONDS: i64 = 1_730_947_200; + const RESETS_AT_MS: u64 = 1_730_947_200_000; + + fn session() -> CodexSession { + CodexSession::new().expect("the session profiles are valid") + } + + fn ready() -> CodexSession { + let mut session = session(); + let _ = session.initialize("lumbridge", "0.0.1"); + let step = session.on_line(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#, 0); + assert!(matches!(step, SessionStep::Send(_)), "handshake completes"); + session + } + + fn read_response(id: u64, percent: i64, minutes: i64) -> String { + format!( + r#"{{"jsonrpc":"2.0","id":{id},"result":{{"rateLimits":{{"primary":{{"usedPercent":{percent},"windowDurationMins":{minutes},"resetsAt":{RESETS_AT_SECONDS}}},"secondary":null}}}}}}"# + ) + } + + #[test] + fn the_two_windows_are_two_separate_account_free_profiles() { + let session = session(); + let ids = session + .profiles() + .iter() + .map(|profile| profile.id().as_str().to_owned()) + .collect::>(); + assert_eq!( + ids, + ["codex-app-server-primary", "codex-app-server-secondary"] + ); + assert!( + !ids.iter().any(|id| id.contains('@')), + "a persisted profile ID must not carry an account identity" + ); + } + + #[test] + fn no_reading_is_requested_before_the_handshake_completes() { + let mut session = session(); + let _ = session.initialize("lumbridge", "0.0.1"); + assert!( + session.poll_frame().is_none(), + "the app-server requires initialize first" + ); + } + + #[test] + fn one_read_is_outstanding_at_a_time() { + let mut session = ready(); + assert!(session.poll_frame().is_some()); + assert!( + session.poll_frame().is_none(), + "a second read must wait for the first to answer" + ); + } + + #[test] + fn a_full_read_becomes_a_provider_reported_observation() { + let mut session = ready(); + let frame = session.poll_frame().expect("ready sessions poll"); + assert!(frame.contains("account/rateLimits/read")); + let step = session.on_line(&read_response(2, 25, 300), RESETS_AT_MS - HOUR_MS); + let SessionStep::Observations(observations) = step else { + panic!("a full read produces observations"); + }; + assert_eq!(observations.len(), 1, "only primary was present"); + let observation = &observations[0]; + assert_eq!(observation.provenance(), UsageProvenance::ProviderReported); + assert_eq!(observation.unit(), Some(UsageUnit::WindowPermille)); + assert_eq!(observation.consumed(), 250); + } + + #[test] + fn an_unchanged_reading_is_not_re_recorded_every_poll() { + let mut session = ready(); + let _ = session.poll_frame(); + let at = RESETS_AT_MS - HOUR_MS; + assert!(matches!( + session.on_line(&read_response(2, 25, 300), at), + SessionStep::Observations(_) + )); + let _ = session.poll_frame(); + assert!( + matches!( + session.on_line(&read_response(3, 25, 300), at + 60_000), + SessionStep::Idle + ), + "an identical reading must not flush the bounded retention" + ); + let _ = session.poll_frame(); + assert!( + matches!( + session.on_line(&read_response(4, 26, 300), at + 120_000), + SessionStep::Observations(_) + ), + "a changed reading is always recorded" + ); + } + + #[test] + fn an_unchanged_reading_is_re_emitted_after_the_floor_so_a_rate_stays_derivable() { + let mut session = ready(); + let _ = session.poll_frame(); + let at = RESETS_AT_MS - 4 * HOUR_MS; + let _ = session.on_line(&read_response(2, 25, 300), at); + let _ = session.poll_frame(); + assert!(matches!( + session.on_line(&read_response(3, 25, 300), at + 300_001), + SessionStep::Observations(_) + )); + } + + #[test] + fn a_sparse_push_without_a_window_records_nothing() { + let mut session = ready(); + let _ = session.poll_frame(); + let at = RESETS_AT_MS - HOUR_MS; + let _ = session.on_line(&read_response(2, 25, 300), at); + // Upstream: nullable metadata absent from a rolling update "does not + // clear a previously observed value". + let push = + r#"{"jsonrpc":"2.0","method":"account/rateLimits/updated","params":{"rateLimits":{}}}"#; + assert!( + matches!(session.on_line(push, at + 1_000), SessionStep::Idle), + "a sparse push must neither clear nor merge" + ); + } + + #[test] + fn a_full_read_that_drops_a_window_records_an_explicit_gap() { + let mut session = ready(); + let _ = session.poll_frame(); + let at = RESETS_AT_MS - HOUR_MS; + let _ = session.on_line(&read_response(2, 25, 300), at); + let _ = session.poll_frame(); + let empty = + r#"{"jsonrpc":"2.0","id":3,"result":{"rateLimits":{"primary":null,"secondary":null}}}"#; + let SessionStep::Observations(observations) = session.on_line(empty, at + 1_000) else { + panic!("a full read that loses a window is a change worth recording"); + }; + assert_eq!(observations.len(), 1); + assert_eq!(observations[0].provenance(), UsageProvenance::Unavailable); + } + + #[test] + fn a_denial_stamps_its_gap_with_now_so_the_ledger_accepts_it() { + use lumbridge_core::UsageLedger; + + let mut session = ready(); + let _ = session.poll_frame(); + let at = RESETS_AT_MS - HOUR_MS; + let SessionStep::Observations(good) = session.on_line(&read_response(2, 25, 300), at) + else { + panic!("the first read succeeds"); + }; + let _ = session.poll_frame(); + let denied = r#"{"jsonrpc":"2.0","id":3,"error":{"code":-32600,"message":"chatgpt authentication required to read rate limits"}}"#; + let SessionStep::Observations(gaps) = session.on_line(denied, at + 60_000) else { + panic!("a denial is a fact about the account"); + }; + + // The ledger is append-only, so a gap stamped behind the reading it + // supersedes would be refused and the stale reading would survive. + let mut ledger = UsageLedger::new(); + for observation in good.into_iter().chain(gaps) { + ledger + .record(observation) + .expect("every observation must be accepted in order"); + } + let projection = ledger.project(session.profiles()[0].id(), at + 60_000); + assert!( + !projection.is_available(), + "the denial must supersede the earlier reading" + ); + } + + #[test] + fn an_api_key_account_reports_unavailable_rather_than_a_fault() { + let mut session = ready(); + let _ = session.poll_frame(); + let denied = r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32600,"message":"chatgpt authentication required to read rate limits"}}"#; + let SessionStep::Observations(observations) = session.on_line(denied, 5_000) else { + panic!("a quota-less account is a fact about the account"); + }; + assert_eq!(observations.len(), 2); + assert!( + observations + .iter() + .all(|observation| observation.provenance() == UsageProvenance::Unavailable) + ); + } + + #[test] + fn any_other_rejection_faults_the_probe() { + let mut session = ready(); + let _ = session.poll_frame(); + let denied = r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32603,"message":"boom"}}"#; + assert!(matches!( + session.on_line(denied, 5_000), + SessionStep::Health(ProbeHealth::Faulted(_)) + )); + } + + #[test] + fn a_credential_refresh_request_is_refused_and_counted() { + let mut session = ready(); + let step = session.on_line( + r#"{"jsonrpc":"2.0","id":99,"method":"account/chatgptAuthTokens/refresh"}"#, + 0, + ); + let SessionStep::Refused { line, class } = step else { + panic!("a credential request must be refused"); + }; + assert_eq!(class, ServerRequestClass::CredentialRefresh); + assert!(line.contains("-32601")); + assert!( + !line.contains("access"), + "a refusal must not echo the request" + ); + assert_eq!(session.refused_credential_requests(), 1); + } + + #[test] + fn a_malformed_line_faults_rather_than_being_guessed_at() { + let mut session = ready(); + assert!(matches!( + session.on_line("this is not json", 0), + SessionStep::Health(ProbeHealth::Faulted(_)) + )); + } + + #[test] + fn window_slots_have_distinct_static_identifiers() { + assert_ne!( + WindowSlot::Primary.profile_id(), + WindowSlot::Secondary.profile_id() + ); + } +} diff --git a/crates/lumbridge-harness/src/jsonrpc.rs b/crates/lumbridge-harness/src/jsonrpc.rs new file mode 100644 index 0000000..487619f --- /dev/null +++ b/crates/lumbridge-harness/src/jsonrpc.rs @@ -0,0 +1,278 @@ +//! A deliberately deaf JSON-RPC client. +//! +//! The Codex app-server can send requests *to* its client, including +//! `account/chatgptAuthTokens/refresh`, which returns a bare access token. +//! AGENTS.md forbids Lumbridge from scraping a harness's private credentials. +//! Choosing not to call that method would be a convention; this module makes +//! it unrepresentable instead. +//! +//! There is no free-form method string anywhere in this crate. Outbound +//! requests come from a two-variant enum, and every inbound server request is +//! answered with `-32601 method not found` regardless of what it asks for. +//! Classification exists only so a refusal can be counted and named, which +//! makes "we were asked for a credential and refused" an auditable event +//! rather than an absence. + +use serde_json::{Value, json}; + +/// JSON-RPC's "method not found". The only reply this client ever sends. +const METHOD_NOT_FOUND: i64 = -32601; +/// Codex returns this for a request that needs an account it does not have. +pub(crate) const INVALID_REQUEST: i64 = -32600; + +/// Every request this crate is able to send. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum OutboundRequest { + Initialize, + ReadAccountRateLimits, +} + +impl OutboundRequest { + pub(crate) const fn method(self) -> &'static str { + match self { + Self::Initialize => "initialize", + Self::ReadAccountRateLimits => "account/rateLimits/read", + } + } +} + +/// The notifications this client accepts. Anything else is ignored. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AcceptedNotification { + AccountRateLimitsUpdated, +} + +impl AcceptedNotification { + pub(crate) fn from_method(method: &str) -> Option { + match method { + "account/rateLimits/updated" => Some(Self::AccountRateLimitsUpdated), + _ => None, + } + } +} + +/// What a server-to-client request was asking for. +/// +/// Purely descriptive. Every class is refused identically; the distinction +/// exists so a probe can report that it declined a credential request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServerRequestClass { + /// A request that would hand Lumbridge a credential. Always refused. + CredentialRefresh, + /// A request to run something on the harness's behalf. + Execution, + /// A request for a human approval decision. + Approval, + /// A request for ambient information such as the current time. + Ambient, + /// Anything this client does not recognise. + Unrecognised, +} + +impl ServerRequestClass { + pub(crate) fn classify(method: &str) -> Self { + match method { + "account/chatgptAuthTokens/refresh" => Self::CredentialRefresh, + "item/tool/call" | "attestation/generate" => Self::Execution, + "item/permissions/requestApproval" => Self::Approval, + "currentTime/read" => Self::Ambient, + _ => Self::Unrecognised, + } + } + + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::CredentialRefresh => "credential refresh", + Self::Execution => "execution", + Self::Approval => "approval", + Self::Ambient => "ambient information", + Self::Unrecognised => "unrecognised request", + } + } +} + +/// A classified inbound line. +#[derive(Clone, Debug)] +pub(crate) enum Inbound { + /// A successful response to one of our two requests. + Result { id: u64, result: Value }, + /// A failed response to one of our two requests. + Failure { id: u64, code: i64 }, + /// An accepted push notification. + Notification { + kind: AcceptedNotification, + params: Value, + }, + /// The server asked us for something. It will be refused. + ServerRequest { + id: Value, + class: ServerRequestClass, + }, + /// A well-formed frame with nothing for us in it. + Ignored, +} + +/// Encodes one of the two permitted requests. +pub(crate) fn encode_request(id: u64, request: OutboundRequest, params: &Value) -> String { + json!({ + "jsonrpc": "2.0", + "id": id, + "method": request.method(), + "params": params, + }) + .to_string() +} + +pub(crate) fn encode_notification(method: &'static str, params: &Value) -> String { + json!({ "jsonrpc": "2.0", "method": method, "params": params }).to_string() +} + +/// The refusal sent for every server-to-client request. +pub(crate) fn encode_refusal(id: &Value) -> String { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": METHOD_NOT_FOUND, "message": "method not found" }, + }) + .to_string() +} + +/// Classifies one line of JSON-RPC without interpreting its payload. +/// +/// Returns `None` when the line is not a JSON object at all, which the caller +/// reports as [`crate::HarnessError::Malformed`]. +pub(crate) fn classify(line: &str) -> Option { + let frame: Value = serde_json::from_str(line).ok()?; + let object = frame.as_object()?; + let method = object.get("method").and_then(Value::as_str); + let id = object.get("id"); + + match (method, id) { + // A method with an id is the server asking us for something. + (Some(method), Some(id)) => Some(Inbound::ServerRequest { + id: id.clone(), + class: ServerRequestClass::classify(method), + }), + // A method without an id is a notification. + (Some(method), None) => Some(AcceptedNotification::from_method(method).map_or( + Inbound::Ignored, + |kind| Inbound::Notification { + kind, + params: object.get("params").cloned().unwrap_or(Value::Null), + }, + )), + // An id without a method is a response to one of ours. + (None, Some(id)) => { + let id = id.as_u64()?; + if let Some(error) = object.get("error") { + let code = error.get("code").and_then(Value::as_i64).unwrap_or(0); + return Some(Inbound::Failure { id, code }); + } + Some(Inbound::Result { + id, + result: object.get("result").cloned().unwrap_or(Value::Null), + }) + } + (None, None) => Some(Inbound::Ignored), + } +} + +#[cfg(test)] +mod tests { + use super::{ + AcceptedNotification, Inbound, OutboundRequest, ServerRequestClass, classify, + encode_refusal, encode_request, + }; + use serde_json::{Value, json}; + + #[test] + fn only_two_methods_can_be_sent() { + assert_eq!(OutboundRequest::Initialize.method(), "initialize"); + assert_eq!( + OutboundRequest::ReadAccountRateLimits.method(), + "account/rateLimits/read" + ); + } + + #[test] + fn a_credential_refresh_request_is_classified_and_refused() { + let line = r#"{"jsonrpc":"2.0","id":9,"method":"account/chatgptAuthTokens/refresh"}"#; + let Some(Inbound::ServerRequest { id, class }) = classify(line) else { + panic!("a method with an id is a server request"); + }; + assert_eq!(class, ServerRequestClass::CredentialRefresh); + let refusal: Value = + serde_json::from_str(&encode_refusal(&id)).expect("the refusal is valid JSON"); + assert_eq!(refusal["error"]["code"], json!(-32601)); + assert!( + refusal.get("result").is_none(), + "a refusal must never carry a result" + ); + } + + #[test] + fn every_server_request_class_is_refused_the_same_way() { + for method in [ + "account/chatgptAuthTokens/refresh", + "item/tool/call", + "item/permissions/requestApproval", + "currentTime/read", + "something/entirely/new", + ] { + let line = format!(r#"{{"jsonrpc":"2.0","id":1,"method":"{method}"}}"#); + let Some(Inbound::ServerRequest { id, .. }) = classify(&line) else { + panic!("{method} must classify as a server request"); + }; + let refusal: Value = serde_json::from_str(&encode_refusal(&id)).expect("valid JSON"); + assert_eq!(refusal["error"]["code"], json!(-32601)); + } + } + + #[test] + fn only_the_rate_limit_notification_is_accepted() { + let accepted = classify( + r#"{"jsonrpc":"2.0","method":"account/rateLimits/updated","params":{"rateLimits":{}}}"#, + ); + assert!(matches!( + accepted, + Some(Inbound::Notification { + kind: AcceptedNotification::AccountRateLimitsUpdated, + .. + }) + )); + let ignored = + classify(r#"{"jsonrpc":"2.0","method":"thread/tokenUsage/updated","params":{}}"#); + assert!(matches!(ignored, Some(Inbound::Ignored))); + } + + #[test] + fn responses_and_failures_are_separated() { + assert!(matches!( + classify(r#"{"jsonrpc":"2.0","id":7,"result":{"rateLimits":{}}}"#), + Some(Inbound::Result { id: 7, .. }) + )); + assert!(matches!( + classify(r#"{"jsonrpc":"2.0","id":7,"error":{"code":-32600,"message":"nope"}}"#), + Some(Inbound::Failure { + id: 7, + code: -32600 + }) + )); + } + + #[test] + fn malformed_and_non_object_lines_are_rejected() { + for line in ["", "not json", "[1,2,3]", "\"a string\"", "{"] { + assert!(classify(line).is_none(), "{line:?} must not classify"); + } + } + + #[test] + fn a_request_encodes_without_a_free_form_method() { + let encoded = encode_request(1, OutboundRequest::ReadAccountRateLimits, &json!({})); + let frame: Value = serde_json::from_str(&encoded).expect("valid JSON"); + assert_eq!(frame["method"], json!("account/rateLimits/read")); + assert_eq!(frame["jsonrpc"], json!("2.0")); + } +} diff --git a/crates/lumbridge-harness/src/lib.rs b/crates/lumbridge-harness/src/lib.rs new file mode 100644 index 0000000..d035d89 --- /dev/null +++ b/crates/lumbridge-harness/src/lib.rs @@ -0,0 +1,68 @@ +//! Documented status and usage probes for supervised coding harnesses. +//! +//! `lumbridge-core` owns the usage ledger and stays free of IO, clocks, and +//! async. This crate is the other side of that boundary: it runs processes, +//! reads a clock, parses untrusted wire text, and hands back +//! [`lumbridge_core::UsageObservation`] values. Nothing here interprets a +//! usage number; it only decides which observation is honest to record. +//! +//! The rule every probe follows: Lumbridge observes documented surfaces of a +//! harness it launched, and never reads that harness's credentials. A probe +//! that cannot obtain a reading records +//! [`lumbridge_core::UsageObservation::unavailable`] rather than a zero. + +#![forbid(unsafe_code)] + +mod child; +pub mod claude; +mod clock; +pub mod codex; +mod jsonrpc; +mod probe; + +pub use child::PROBE_ENV_ALLOWLIST; +pub use clock::MonotonicWallClock; +pub use jsonrpc::ServerRequestClass; +pub use probe::{ProbeHealth, ProbeOutcome, UsageProbe}; + +use thiserror::Error; + +/// Failures a probe can report. +/// +/// Every variant is [`Copy`] and carries only a static discriminator, an +/// [`std::io::ErrorKind`], or a number. No variant can capture a string from a +/// child process or the filesystem, so an error can never smuggle a path, +/// a credential, or attacker-chosen text into a log or a UI surface. +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum HarnessError { + #[error("a probe program name must not be empty")] + EmptyProgram, + #[error("the environment variable {0} is not on the probe allowlist")] + EnvNotAllowlisted(&'static str), + #[error("a probe poll interval must be at least one second")] + ProbeIntervalTooShort, + #[error("the probe program could not be started ({0:?})")] + SpawnFailed(std::io::ErrorKind), + #[error("the probe program closed its output")] + Eof, + #[error("the probe program sent a {0} byte line, over the accepted limit")] + OversizedLine(usize), + #[error("the probe program sent a line that is not a JSON-RPC frame")] + Malformed, + #[error("the harness rejected the request with JSON-RPC code {0}")] + Rejected(i64), + #[error("the harness has no subscription account to report a quota for")] + NotAuthenticated, +} + +impl HarnessError { + /// Whether this failure means "there is no number", as opposed to + /// "the probe broke". + /// + /// Both record an unavailable observation, but only the second is a fault + /// worth surfacing as a broken probe. + #[must_use] + pub const fn is_expected_gap(self) -> bool { + matches!(self, Self::NotAuthenticated) + } +} diff --git a/crates/lumbridge-harness/src/probe.rs b/crates/lumbridge-harness/src/probe.rs new file mode 100644 index 0000000..c535a36 --- /dev/null +++ b/crates/lumbridge-harness/src/probe.rs @@ -0,0 +1,106 @@ +//! The contract between a harness probe and the usage ledger. +//! +//! This trait lives here rather than in `lumbridge-core` because implementing +//! it implies a fallible external call, and core stays IO-free, clock-free, +//! and async-free. [`lumbridge_core::UsageObservation`] is already the shared +//! type; nothing further needs to cross the boundary. + +use lumbridge_core::{AccountProfile, UsageObservation}; + +use crate::HarnessError; + +/// What a probe knows about its own liveness. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProbeHealth { + /// Launched, handshake not finished. + Starting, + /// Handshake complete and readings are expected. + Ready, + /// Running, but this account has no subscription quota to report. This is + /// a correct terminal state, not a fault: API-key users have no window. + NoQuotaAccount, + /// Broken. The observations it produced before this are still facts. + Faulted(HarnessError), + /// Deliberately shut down. + Stopped, +} + +impl ProbeHealth { + #[must_use] + pub const fn is_faulted(self) -> bool { + matches!(self, Self::Faulted(_)) + } + + /// A short phrase a UI may render beside the probe's profiles. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Starting => "probe starting", + Self::Ready => "probe ready", + Self::NoQuotaAccount => "no subscription quota on this account", + Self::Faulted(_) => "probe faulted", + Self::Stopped => "probe stopped", + } + } +} + +/// One round of probe output. +/// +/// It carries observations and liveness and nothing else. There is no variant +/// able to carry a command, a plan, or a capability request, so a probe cannot +/// become an actor on the workspace no matter what the harness sends it. +#[derive(Clone, Debug)] +pub struct ProbeOutcome { + observations: Vec, + health: ProbeHealth, +} + +impl ProbeOutcome { + #[must_use] + pub const fn new(observations: Vec, health: ProbeHealth) -> Self { + Self { + observations, + health, + } + } + + #[must_use] + pub const fn idle(health: ProbeHealth) -> Self { + Self { + observations: Vec::new(), + health, + } + } + + #[must_use] + pub fn observations(&self) -> &[UsageObservation] { + &self.observations + } + + /// Takes the observations out for recording, leaving the outcome empty. + #[must_use] + pub fn into_observations(self) -> Vec { + self.observations + } + + #[must_use] + pub const fn health(&self) -> ProbeHealth { + self.health + } +} + +/// A source of usage observations for one or more account profiles. +pub trait UsageProbe { + /// Drains whatever the probe has ready. + /// + /// Implementations must return promptly: this is called from the UI's + /// frame loop and must never wait on a child process, a socket, or a lock + /// held across IO. + fn poll(&mut self) -> ProbeOutcome; + + /// The profiles this probe can produce observations for. + fn profiles(&self) -> &[AccountProfile]; + + /// Stops the probe and releases its child process. + fn shutdown(&mut self); +} diff --git a/crates/lumbridge-harness/tests/codex_ledger_path.rs b/crates/lumbridge-harness/tests/codex_ledger_path.rs new file mode 100644 index 0000000..3f7dcca --- /dev/null +++ b/crates/lumbridge-harness/tests/codex_ledger_path.rs @@ -0,0 +1,175 @@ +//! End-to-end: a Codex transcript through the real ledger and footer. +//! +//! The unit tests prove the adapter builds the right observations. These prove +//! the observations still obey every decision 0012 rule once they reach +//! `lumbridge-core`, which is the property that actually matters to a user +//! reading the footer. +//! +//! Every transcript below is hand-written from the documented wire shape in +//! openai/codex (Apache-2.0), commit +//! `17e8101699c5062117d0d37f504313e8af53b043`, `codex-rs/app-server/README.md` +//! section "7) Rate limits (`ChatGPT`)". No real account, transcript, or +//! credential is used, and all timestamps are small synthetic integers. + +use lumbridge_core::{ + AccountProfileId, FooterUsage, UsageConfidence, UsageLedger, UsageProvenance, +}; +use lumbridge_harness::codex::replay_transcript; + +const HOUR_MS: u64 = 3_600_000; +/// A synthetic reset five hours after the epoch, in seconds. +const RESETS_AT_SECONDS: u64 = 5 * 3_600; +const RESETS_AT_MS: u64 = 5 * HOUR_MS; + +fn initialize_response() -> &'static str { + r#"{"jsonrpc":"2.0","id":1,"result":{"userAgent":"codex"}}"# +} + +fn read_response(id: u64, percent: u64) -> String { + format!( + r#"{{"jsonrpc":"2.0","id":{id},"result":{{"rateLimits":{{"primary":{{"usedPercent":{percent},"windowDurationMins":300,"resetsAt":{RESETS_AT_SECONDS}}},"secondary":null,"rateLimitReachedType":null}}}}}}"# + ) +} + +fn primary() -> AccountProfileId { + AccountProfileId::new("codex-app-server-primary").expect("the probe's static ID is valid") +} + +fn ledger_from(frames: &[(&str, u64)]) -> UsageLedger { + let mut ledger = UsageLedger::new(); + for observation in replay_transcript(frames) { + ledger + .record(observation) + .expect("the adapter emits observations in time order"); + } + ledger +} + +#[test] +fn a_two_reading_transcript_produces_a_provider_reported_footer() { + let first = read_response(2, 20); + let second = read_response(3, 35); + let ledger = ledger_from(&[ + (initialize_response(), 0), + (first.as_str(), HOUR_MS), + (second.as_str(), 2 * HOUR_MS), + ]); + + let projection = ledger.project(&primary(), 2 * HOUR_MS); + assert_eq!(projection.provenance(), UsageProvenance::ProviderReported); + assert_eq!(projection.confidence(), UsageConfidence::Exact); + assert_eq!(projection.consumed_permille(), Some(350)); + assert_eq!(projection.resets_in_ms(), Some(3 * HOUR_MS)); + // 200 permille burned over one hour, derived rather than reported. + assert_eq!(projection.burn_per_hour(), Some(150)); + assert_eq!(projection.burn_provenance(), UsageProvenance::Estimated); +} + +#[test] +fn a_single_reading_yields_a_percentage_but_no_rate() { + let only = read_response(2, 20); + let ledger = ledger_from(&[(initialize_response(), 0), (only.as_str(), HOUR_MS)]); + + let projection = ledger.project(&primary(), HOUR_MS); + assert_eq!(projection.consumed_permille(), Some(200)); + assert_eq!( + projection.burn_per_hour(), + None, + "one provider reading is not a rate" + ); + assert_eq!(projection.exhaustion_in_ms(), None); +} + +#[test] +fn an_api_key_account_renders_the_unavailable_path_rather_than_a_zero() { + // Codex answers -32600 with "chatgpt authentication required to read rate + // limits" for an account that has no subscription quota. + let denied = r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32600,"message":"chatgpt authentication required to read rate limits"}}"#; + let ledger = ledger_from(&[(initialize_response(), 0), (denied, HOUR_MS)]); + + let projection = ledger.project(&primary(), HOUR_MS); + assert!(!projection.is_available()); + assert_eq!(projection.consumed_permille(), None); + + let profile = lumbridge_core::AccountProfile::new( + "codex-app-server-primary", + "Codex", + "ChatGPT", + "primary window", + "subscription", + ) + .expect("a valid profile"); + let footer = FooterUsage::new(&profile, &projection); + assert_eq!(footer.window(), "usage unavailable"); + assert_eq!(footer.burn(), "burn rate unavailable"); + assert_eq!(footer.trust(), "no usage source"); + assert!( + !footer.to_string().contains('%'), + "an account with no quota must not render a percentage" + ); +} + +#[test] +fn a_reading_taken_after_the_reset_never_renders_a_frozen_percentage() { + let stale = read_response(2, 90); + let ledger = ledger_from(&[ + (initialize_response(), 0), + // Observed one millisecond after the window it describes ended. + (stale.as_str(), RESETS_AT_MS + 1), + ]); + + let projection = ledger.project(&primary(), RESETS_AT_MS + 1); + assert!(!projection.is_available()); + assert_eq!( + projection.consumed_permille(), + None, + "an ended window must not keep rendering its last percentage" + ); +} + +#[test] +fn a_credential_refresh_request_produces_no_observation_at_all() { + let refresh = r#"{"jsonrpc":"2.0","id":77,"method":"account/chatgptAuthTokens/refresh"}"#; + let reading = read_response(2, 20); + let observations = replay_transcript(&[ + (initialize_response(), 0), + (refresh, 1_000), + (reading.as_str(), HOUR_MS), + ]); + assert_eq!( + observations.len(), + 1, + "the refused credential request must not enter the usage stream" + ); +} + +#[test] +fn a_repeated_identical_reading_does_not_flood_the_ledger() { + let a = read_response(2, 20); + let b = read_response(3, 20); + let c = read_response(4, 20); + let ledger = ledger_from(&[ + (initialize_response(), 0), + (a.as_str(), HOUR_MS), + (b.as_str(), HOUR_MS + 60_000), + (c.as_str(), HOUR_MS + 120_000), + ]); + assert_eq!( + ledger.observation_count(&primary()), + 1, + "identical readings inside the re-emit floor must not evict history" + ); +} + +#[test] +fn a_malformed_transcript_records_nothing_rather_than_guessing() { + let observations = replay_transcript(&[ + (initialize_response(), 0), + ("}{ not json", 1_000), + ( + r#"{"jsonrpc":"2.0","id":2,"result":{"rateLimits":"a string"}}"#, + 2_000, + ), + ]); + assert!(observations.is_empty()); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c4ec46e..142e5f6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,7 +28,8 @@ messages should be real from the beginning. - `lumbridge-pty`: portable PTY and process-tree adapters. - `lumbridge-terminal`: VT parsing, scrollback, selection, search, render model. - `lumbridge-acp`: ACP client, capability negotiation, transcript normalization. -- `lumbridge-harness`: manifests, launch profiles, hooks, PTY fallback adapters. +- `lumbridge-harness`: manifests, launch profiles, hooks, PTY fallback adapters, + and documented status/usage probes. - `lumbridge-provider`: BYOK providers and provider-neutral usage records. - `lumbridge-storage`: SQLite migrations, event log, snapshots, retention. - `lumbridge-remote`: OpenSSH/Tailscale command construction, framed stdio, @@ -41,10 +42,10 @@ messages should be real from the beginning. - `lumbridge`: installable application entry point. The scaffold currently contains `lumbridge-core`, `lumbridge-storage`, -`lumbridge-buzz`, `lumbridge-pty`, `lumbridge-runtime`, `lumbridge-terminal`, and -the entry point. `lumbridge-core` also contains the first typed workspace -command reducer. Larger runtime crates are added after their architecture spikes -pass. +`lumbridge-buzz`, `lumbridge-pty`, `lumbridge-runtime`, `lumbridge-terminal`, +`lumbridge-harness`, and the entry point. `lumbridge-core` also contains the +first typed workspace command reducer and the usage ledger. Larger runtime +crates are added after their architecture spikes pass. ## Terminal path @@ -200,6 +201,52 @@ Observations include account profile, provider, harness, model, units, time window, reset time, provenance, confidence, and source timestamp. Projections are derived views that can be recomputed as forecasting improves. +`lumbridge-core` now implements that stream. A bounded per-profile `UsageLedger` +accepts observations in time order, and `project` derives the footer view: +window fraction, reset, burn rate, and exhaustion. Facts keep their reported +provenance; every derived value is labelled estimated. The projection withholds +a burn rate from a single sample, withholds a window fraction without a reported +ceiling, withholds an exhaustion estimate that falls after the reset, treats an +unavailable observation as invalidating older facts, and reports an expired +window as rolled over rather than freezing its last percentage. `FooterUsage` +renders the five product-spec questions and returns explicit unavailable phrases +instead of placeholder numbers. See decision 0012. + +`lumbridge-harness` is the other side of that boundary: it runs processes, +reads a clock, and parses untrusted wire text, then hands back observations. +Its first adapter probes Codex's documented `account/rateLimits/read` surface +over the app-server's JSON-RPC stdio protocol. The client cannot express a +request outside a two-variant enum and answers every server-to-client request +with `-32601`, so a harness asking Lumbridge for a credential is refused by +construction. All protocol decisions live in a pure state machine driven by +`&str` lines and an explicit timestamp, with a `replay_transcript` entry point +that verifies the whole path against a fixture without a process, a clock, or a +network. See decision 0013. + +Its second adapter reads Claude Code's session transcripts, the only local +surface that reports what that harness spent. It is a tail follower with a +per-file byte offset, so its total is monotonic, and it reports nothing until +it has read the existing backlog to the end — a partially-read backlog is +indistinguishable from a burst of spend, and the ledger would derive a rate +from it. The transcript carries no window or ceiling, so that profile reports +consumption against none. The parser models four token counters and nothing +else, so the conversations in those files are not representable in a Lumbridge +value. See decision 0014. + +Claude Code's subscription windows come from a different surface: the CLI pipes +a `rate_limits` object carrying the five-hour and seven-day windows to whatever +`statusLine` command the user has configured, on every turn. A small installed +bridge writes those fields — and only those — to a local feed the probe tails, +so the windows are `ProviderReported` and no credential is ever read. Reading +Claude Code's OAuth token would be more capable and is what comparable tools +do; Lumbridge does not, because AGENTS.md forbids it. See decision 0015. + +The GPUI shell now runs both probes; a probe contributes its own profiles on +top of the declared ones, so the Claude windows appear once they report. Its +footer strip +shows every declared harness at once, and a harness with no adapter renders +the honest gap. + Subscription balance is provider-specific and sometimes unavailable. BYOK calls usually expose token counts but cost still depends on cached tokens, reasoning, tool calls, and current pricing. Adapters normalize facts without erasing their diff --git a/docs/UI_SPIKE_SCORECARD.md b/docs/UI_SPIKE_SCORECARD.md index cd37a32..3fc9ba4 100644 --- a/docs/UI_SPIKE_SCORECARD.md +++ b/docs/UI_SPIKE_SCORECARD.md @@ -97,7 +97,13 @@ Terminal, Browser, Markdown, and Review panels beside the selection or reattaches an existing session. Every Terminal panel receives an independent actor-owned VT session; the three seed non-terminal surfaces keep deterministic updates running. Counters -separate external PTY batches/lines from total model updates. The GPUI footer +separate external PTY batches/lines from total model updates. The GPUI footer's +usage zone is no longer a constant string: it renders a `lumbridge-core` +projection for the selected panel's account profile, with a provenance chip and +explicit unavailable phrases, and its sidebar attention count and runtime rows +are derived from live panel and ledger state. The Floem shell still renders the +static footer fixtures, so the two candidates are no longer comparable on that +strip. The GPUI footer also reports dispatch-to-element-build p50/p95 over a bounded 256-sample window. It is deliberately not called key-to-present or frame-present latency: neither candidate exposes a reliable public cross-platform post-present callback. True diff --git a/docs/decisions/0012-usage-ledger-and-footer-provenance.md b/docs/decisions/0012-usage-ledger-and-footer-provenance.md new file mode 100644 index 0000000..e279ce4 --- /dev/null +++ b/docs/decisions/0012-usage-ledger-and-footer-provenance.md @@ -0,0 +1,67 @@ +# 0012: The footer renders a derived projection, never a stored percentage + +Status: accepted. Supplemented by decisions 0013 and 0014, which supply the +first two real adapters. Every rule below still holds. + +Decision 0002 established that usage observations carry provenance. This +decision fixes how those observations become the five answers the footer owes +the user, and what the footer must do when it cannot answer. + +## The contract + +`lumbridge-core` owns an append-only `UsageLedger` of `UsageObservation`s per +`AccountProfile`. An observation is a fact: consumed units, an optional ceiling, +an optional provider window, a provenance, and the time it was observed. The +ledger never edits an observation and refuses one that predates the newest entry +for that profile, because an append-only stream cannot move backwards. + +`UsageLedger::project` derives the footer view. Reported facts keep their own +provenance. Anything the projection computes is labelled `Estimated` even when +every input was provider-reported, because a derived rate is not a reported +fact. + +## Rules the projection enforces + +- A burn rate needs at least two observations inside the same window, the same + unit, and a minimum elapsed sample. One reading yields no rate at all rather + than a rate of zero. +- A window fraction requires a reported ceiling. A self-hosted endpoint has no + quota, so it reports consumption and explicitly says no ceiling exists. +- An exhaustion estimate is withheld when the window resets first. Telling a + user they will run out after the quota has already refilled is worse than + telling them nothing. +- An `Unavailable` observation invalidates older facts for that profile instead + of letting a stale reading keep rendering as current. +- An expired window is reported as rolled over, not as a frozen percentage from + a window that ended. +- Staleness is surfaced, not hidden: past the staleness budget the value still + renders but its confidence drops to unknown and the footer says so. + +## What the UI may do + +A compact surface may substitute the short provenance label. It may not drop the +label, round an estimate into a reported fact, or substitute a placeholder +number for a missing one. `FooterUsage` therefore returns explicit phrases — +`usage unavailable`, `reset time unavailable`, `burn rate unavailable`, +`no usage source` — and the GPUI shell renders unavailable values in the quiet +tone with the provenance chip coloured by source rather than by value. + +A pane with no harness attached shows the detached roll-up. It never inherits +another pane's account, because attributing one agent's spend to another is the +same class of untruth as inventing the number. + +## Status of the numbers + +The GPUI shell's observations are still a declared spike fixture. The path is +real: the fixture records through the same ledger and reads back through the +same projection, so the honest-gap behaviour above is exercised on every frame +rather than asserted only in tests. + +The first real adapter now exists in `lumbridge-harness` (decision 0013) and its +integration tests drive a synthetic Codex transcript through this ledger and +this footer, proving every rule above survives the adapter path. Pointing the +running shell at a live account spawns a process against the user's own +subscription, so it stays a separate, user-consented step. + +All arithmetic is integer arithmetic and fractions are carried as permille, so a +rendered percentage cannot drift from the stored fact. diff --git a/docs/decisions/0013-first-usage-adapter-codex-app-server.md b/docs/decisions/0013-first-usage-adapter-codex-app-server.md new file mode 100644 index 0000000..277e3fe --- /dev/null +++ b/docs/decisions/0013-first-usage-adapter-codex-app-server.md @@ -0,0 +1,116 @@ +# 0013: The first usage adapter is the Codex app-server rate-limit probe + +Status: accepted; wire contract verified against one live account. + +Decision 0012 built a usage ledger and a footer that refuse to invent numbers, +but every observation reaching them was a declared fixture. This decision picks +the first real source and records the rules that choice establishes for the +adapters after it. + +## Why Codex first + +Of the seven captured harnesses, Codex is the only one whose quota surface is +all four of: prose-documented upstream with a worked example +(`codex-rs/app-server/README.md`, "7) Rate limits (ChatGPT)"), pinned by a +checked-in JSON Schema, genuinely provider-originated, and readable without +Lumbridge touching a credential. `account/rateLimits/read` returns +`usedPercent`, `windowDurationMins`, and `resetsAt`; the app-server parses +those out of the backend's `x-codex-*-used-percent` response headers. + +Lumbridge launches `codex app-server` and lets the harness resolve its own +authentication from `CODEX_HOME`. That satisfies the product spec's +"observes only documented status, usage, and protocol surfaces" without any of +the credential handling AGENTS.md forbids. Upstream is Apache-2.0; only the +observable wire contract is mirrored, and its source, commit, and license are +recorded in the parser and the tests. + +## The provenance test for every future adapter + +Ask whether the harness **computes** the value or **forwards** it. + +- Forwards a provider's own number: `ProviderReported`. +- Computes, counts, or estimates it: `HarnessReported`, or `LocallyMeasured` + when Lumbridge did the counting. + +Codex forwards, so its windows are provider-reported. The ACP `usage_update` +notification, by contrast, is a *context window* gauge, and at least one +captured harness fills it with an explicit local estimate. It must never be fed +through the same path: a context window is not a quota, and rendering one as +"62% used" would imply a subscription limit that does not exist. + +## Rules this adapter establishes + +- **Refusal is structural, not procedural.** The app-server can send its client + `account/chatgptAuthTokens/refresh`, which returns a bare access token. The + client has no free-form method string: outbound requests come from a + two-variant enum, and every server-to-client request is answered `-32601` + regardless of what it asks for. Asking for a credential is unrepresentable + rather than merely declined, and refusals are counted so the path is + auditable. +- **An account with no quota is a fact, not a fault.** Codex answers `-32600` + for an API-key user. That records an unavailable observation for both + windows. Any other rejection faults the probe. +- **Two windows are two profiles.** `primary` and `secondary` get separate, + account-free profile identifiers. Merging them would mean silently choosing + one. A profile ID is persisted, so it never carries an account identity. +- **A sparse push neither clears nor merges.** Upstream documents that nullable + values absent from a rolling update "do not clear a previously observed + value". An absent window on a notification therefore records nothing. It is + also not merged into the last full read: a merged composite would present + itself as provider-reported while being partly a memory. +- **Clock disagreement costs the window, not the number.** A window whose + computed start is after the observation is dropped while the provider's + percentage is kept. A reading taken at or after `resetsAt` is unusable + entirely, because 0012 requires an ended window to read as rolled over. +- **Identical readings are suppressed, with a floor.** The ledger retains a + bounded history per profile; re-recording an unchanged percentage every poll + would evict real history and destroy the burn baseline. An unchanged reading + is re-emitted only after five minutes, which keeps a rate derivable without + flooding. +- **Errors cannot carry text.** `HarnessError` is `Copy` and holds only static + discriminators, an `io::ErrorKind`, and numbers. A spawn failure deliberately + discards the underlying error so a filesystem path cannot travel into a log, + and a rejection keeps the JSON-RPC code while discarding the backend message. +- **The child is contained.** Cleared environment with a literal allowlist, + pipes rather than a PTY, stderr discarded, and a per-line byte cap. The child + leads its own process group and the group is what gets killed, because a pipe + reports end of file only when every write end closes: a launcher that execs + the real program as a grandchild with inherited stdio — which is how the npm + distribution of Codex works — would otherwise leave a reader blocked on a pipe + nothing will ever write to. Shutdown signals, kills the group, and joins only + the worker. The reader is never joined, so a descendant that escapes the group + can leak a parked thread but cannot hang the caller, which matters because + `Drop` calls shutdown. + +## Verified against a live account + +One reading was taken with the user's consent on 2026-08-31, against +`codex-cli 0.145.0` installed as a standalone native binary. It confirmed the +whole contract: `usedPercent` an integer, `resetsAt` Unix seconds, `secondary` +null, and no unmodelled fields inside the window object. The result body also +carried `rateLimitResetCredits`, `rateLimitsByLimitId`, `planType`, `limitId`, +`limitName`, `individualLimit`, `credits`, and `spendControlReached`, all +correctly ignored by the parser. Two unsolicited notifications arrived during +the exchange — `configWarning` and `remoteControl/status/changed` — which +exercised the ignore path with real traffic. No server-to-client request +arrived, so the refusal path remains proven only by test. + +The live account's `windowDurationMins` was **10080**: a weekly window, not the +fifteen-minute example in the upstream README. That exposed a real defect no +fixture built from the documented example would have caught — +`format_duration_ms` rendered it as "151h 30m" — and days were added to the +formatter as a result. Fixtures agree with documentation; only a real account +disagrees with your assumptions. + +## What is still not true + +The whole test suite runs without the `codex` binary, a `ChatGPT` account, or +network access, and that property must be preserved: CI must never depend on an +installed harness. A single live reading verifies the shape of one account on +one plan; it does not exercise the `secondary` window, the `-32600` no-quota +path, or a window that rolls mid-read. Those remain covered by synthetic +transcripts only. + +Wiring the probe into the running shell means spawning a process against the +user's own subscription account on every launch. Until that is wired, the shell +keeps rendering its declared fixture. diff --git a/docs/decisions/0014-claude-code-transcript-usage.md b/docs/decisions/0014-claude-code-transcript-usage.md new file mode 100644 index 0000000..5060c78 --- /dev/null +++ b/docs/decisions/0014-claude-code-transcript-usage.md @@ -0,0 +1,103 @@ +# 0014: Claude Code usage comes from its transcripts, and reports spend without a ceiling + +Status: accepted for token spend. **Corrected by decision 0015**: this record +originally claimed Claude Code exposes no quota window. That was wrong — it +does, through the status line — and the claim is struck below rather than +quietly edited out, because the reasoning that followed from it shaped the +adapter. + +Decision 0013 established the provenance test for a usage adapter: does the +harness forward a provider's number, or record its own? Claude Code is the case +that makes the second half of that test matter. + +## Correction + +The original text asserted that "Claude Code publishes no quota API and no +remaining-balance command" and that a user asking how much they have left +"gets the honest answer — Lumbridge can show what was spent, and cannot show +what remains, because Claude Code does not say." + +**Both statements are false.** Claude Code 2.1.80 and later pipe a +`rate_limits` object with `five_hour` and `seven_day` windows to the configured +`statusLine` command on every turn, and the CLI documents that shape itself. +There is also an account usage endpoint. The correct claim is narrower and is +the only one this record still makes: *the transcript* carries spend and not +quota. Decision 0015 covers the window surface. + +## The surface + +What Claude Code writes to disk per session is the API's `usage` object for +every assistant turn, +appended to a per-session JSONL transcript under +`$CLAUDE_CONFIG_DIR/projects` (default `~/.claude/projects`). Each assistant +record carries `input_tokens`, `output_tokens`, +`cache_creation_input_tokens`, and `cache_read_input_tokens`, alongside the +model that produced it. + +Summing those is how this adapter reports spend. **A subscription's window, +ceiling, and reset are not in the transcript**, so this profile reports +consumption against no ceiling rather than implying a quota — but that is a +fact about this file, not about Claude Code, and the quota is read separately +under decision 0015. + +The `statusLine` hook does require mutating the user's `settings.json`, which +is why the transcript came first: reading a file the harness already writes +needs no change to the user's configuration. That was a good reason to build +this adapter, and a bad reason to conclude the other surface did not exist. + +## Why harness-reported + +Claude Code forwards the provider's per-response counts faithfully, so a single +record's numbers originate with the provider. But a running total is the +*harness's* record — an append-only log Lumbridge is reading, not a provider's +statement of account. Under 0013's rule the value is therefore +`HarnessReported`, and the footer says `harness · approximate`. + +## Content blindness is structural, not procedural + +**These files contain the user's conversations.** The parser models exactly +four integers, a record type, and a model name. Every other field — message +text, tool inputs, file contents, `service_tier`, `inference_geo`, +`server_tool_use` — is discarded by serde during parsing, because there is no +field in the wire types that could hold it. A test asserts that no debug +rendering of a parsed record can contain message content. + +That is the same discipline as the Codex probe's refusal path: the safe +behaviour is made unrepresentable rather than merely avoided. + +## What "tokens" counts + +The reported number is `input + output + cache_creation + cache_read`. + +Cache reads are included because they are billed and do count against a +subscription window, even though they are re-reads of context already counted +once. Excluding them would understate consumption. Weighting them by price +would require a price list this adapter does not have, and would turn a counted +fact into an estimate. In practice cache reads dominate the total — on the +transcripts this was verified against they were roughly 98% of it — so the +derived burn rate is largely a measure of context re-read per turn rather than +of new work. The split is preserved in `TokenTally` for a detail view that can +say so. + +## Priming before reporting + +The probe is a tail follower: it remembers a byte offset per transcript and +reads only what was appended since, so its running total is monotonic by +construction. The ledger rejects an observation that moves a profile backwards, +so this is a correctness requirement, not an optimisation. + +It also **reports nothing until it has read every existing transcript to its +end**. A partially-read backlog is indistinguishable from an enormous burst of +spend, and the ledger will derive a burn rate from it: the first run against a +20 MB backlog with an 8 MB per-poll budget reported **forty-six billion tokens +an hour**, which was catch-up, not usage. The first reading must be a true +baseline before any rate derived from it can mean anything. A shrinking file +resets its own offset rather than trusting a position into different content. + +## Bounds + +A scan walks at most six directory levels and 4096 files, never follows +symlinks out of the tree, reads at most 8 MB per poll, and refuses a single +record over 1 MB while still advancing past it. A transcript's last line is +routinely a partial record still being written; it is left unconsumed for the +next read rather than parsed or skipped. diff --git a/docs/decisions/0015-claude-code-subscription-windows.md b/docs/decisions/0015-claude-code-subscription-windows.md new file mode 100644 index 0000000..43246a9 --- /dev/null +++ b/docs/decisions/0015-claude-code-subscription-windows.md @@ -0,0 +1,97 @@ +# 0015: Claude Code's subscription windows come from the status line, not the credential + +Status: accepted; bridge installed and verified end to end. + +Decision 0014 concluded that Claude Code cannot report a remaining balance. +That was wrong, and the error was one of not looking rather than of reasoning: +the transcript genuinely carries no quota, and that fact was generalised into a +claim about the harness. Claude Code reports its quota through three other +channels. + +## The four surfaces + +| Surface | Carries | Cost | Needs a credential | +|---|---|---|---| +| **Status line `rate_limits`** | `five_hour`, `seven_day` — `used_percentage` + `resets_at` | none; relayed from headers the CLI already received | no | +| Account usage endpoint | the same windows, plus per-model scoped limits | tight budget; 429s under polling | **yes** | +| Stream `rate_limit_info` | `status`, `rateLimitType`, `unifiedWindows` | none | no, but requires driving the harness | +| `/usage` in a PTY | rendered text | a turn | no, but screen-scraped | + +The status-line shape is documented inside the CLI itself: + +```text +"five_hour": { // present only while the API reports it and its resets_at has not passed + "used_percentage": number, // Percentage of limit used (0-100) + "resets_at": number // Unix epoch seconds when this window resets +} +``` + +`rate_limits_available` is the honest-gap signal, also documented: "False when +plan rate limits do not apply (API key, Bedrock, Vertex, or missing profile +scope)". An account with no subscription window says so, rather than leaving +Lumbridge to infer it from an absence. + +## Why the status line and not the endpoint + +The account usage endpoint is more authoritative — it answers without an active +session and exposes per-model scoped limits. It also requires reading Claude +Code's OAuth credential, and AGENTS.md says Lumbridge "must not scrape their +private credentials." Other tools in this space do read it; that is their call +to make, and it is a real capability difference. + +Lumbridge keeps the rule. The status line is pushed *to* a command the user +installs, so no credential is ever touched, and the data is free because the +CLI is relaying rate-limit headers it already received on its own API calls. +The cost is that a window only appears once a session has run, and that +installation edits `settings.json`. + +The endpoint is recorded here as a rejected-for-now alternative rather than an +unconsidered one. Reopening it means amending AGENTS.md first. + +## Provenance + +`ProviderReported`. Under decision 0013's test the CLI forwards the provider's +number rather than computing one — the same standing as Codex's forwarded +`x-codex-*` headers. The footer shows `provider · exact`. + +## The bridge + +`scripts/claude-statusline-bridge.sh` is installed as Claude Code's +`statusLine` command. It appends the rate-limit fields — and only those — to +`$XDG_DATA_HOME/lumbridge/claude-rate-limits.jsonl`, and prints a status line. + +Three properties it must hold: + +- **It cannot fail.** A status-line command that errors or hangs degrades the + user's session, so every step is guarded and it always exits 0 having + printed something. +- **It copies nothing else.** The payload also carries the session's cost, + transcript path, working directory, and model. The bridge builds a fresh + record from three numeric fields per window rather than filtering the + original, so there is no path by which the rest travels. +- **It stays bounded.** The feed rotates past 256 KB. Lines are appended rather + than overwritten so concurrent sessions do not clobber each other; the probe + reads the newest line, because each line is a complete snapshot and an older + one is superseded, never summed. + +Installation is a separate, explicit, reversible script — not something a probe +does on the user's behalf. It backs up `settings.json`, writes through a +temporary file, refuses to replace a `statusLine` someone else configured, and +supports `--dry-run` and `--uninstall`. + +## Window handling + +Each window becomes its own account-free profile — `claude-code-five-hour` and +`claude-code-seven-day` — because two windows are two facts about two quota +periods, and merging them means silently picking one. The pane maps to the +five-hour window: it is the limit that actually stops work. + +A reading's window start is derived from `resets_at` minus the documented +window length, which is what lets the ledger scope a burn rate to one quota +period. Two rules follow decision 0012 unchanged: a window whose `resets_at` +has passed is stale, so its reset is dropped and only the percentage survives; +and a start the local clock places in the future costs the start, not the +reset, which is the half that bounds a forecast. + +A percentage above 100 — possible on a gateway spend limit once breached — is +clamped to the whole rather than stored as more than a full window. diff --git a/scripts/claude-statusline-bridge.py b/scripts/claude-statusline-bridge.py new file mode 100644 index 0000000..a1ecba4 --- /dev/null +++ b/scripts/claude-statusline-bridge.py @@ -0,0 +1,108 @@ +"""Extract Claude Code's rate-limit windows from a status-line payload. + +Invoked by `claude-statusline-bridge.sh` with the feed path as argv[1] and the +status-line JSON on stdin. Appends only the rate-limit fields to the feed and +prints a status line. + +Must never fail: a status-line command that errors degrades the user's Claude +Code session, so every step is guarded and this always exits 0 having printed. +""" + +import json +import os +import sys + +# Keep the feed bounded. The probe only needs the newest line; older ones are +# superseded snapshots, not history worth keeping. +MAX_FEED_BYTES = 256 * 1024 +WINDOW_NAMES = ("five_hour", "seven_day") +PERCENT_KEYS = ("used_percentage", "utilization", "resets_at") + + +def extract(payload): + """Return the record to append: rate-limit fields and nothing else. + + The payload also carries the session's cost, transcript path, working + directory, and model. None of that is Lumbridge's business, and none of it + is copied — this builds a fresh dict rather than filtering the original. + """ + record = {"rate_limits_available": payload.get("rate_limits_available")} + limits = payload.get("rate_limits") + if not isinstance(limits, dict): + return record + windows = {} + for name in WINDOW_NAMES: + window = limits.get(name) + if not isinstance(window, dict): + continue + kept = { + key: window[key] + for key in PERCENT_KEYS + if isinstance(window.get(key), (int, float)) + and not isinstance(window.get(key), bool) + } + if kept: + windows[name] = kept + if windows: + record["rate_limits"] = windows + return record + + +def status_line(record): + if record.get("rate_limits_available") is False: + return "lumbridge · no plan limits on this account" + windows = record.get("rate_limits") + if not windows: + return "lumbridge · no window reported yet" + parts = [] + for name, label in (("five_hour", "5h"), ("seven_day", "7d")): + window = windows.get(name) + if not window: + continue + percent = window.get("used_percentage", window.get("utilization")) + if percent is None: + continue + parts.append(f"{label} {max(0.0, 100.0 - float(percent)):.0f}% left") + if not parts: + return "lumbridge · no window reported yet" + return "lumbridge · " + " · ".join(parts) + + +def append(feed, record): + if not feed: + return + try: + # Rotate rather than grow without bound. The probe resets its read + # offset when the file shrinks, so a rotation costs one reading. + if os.path.exists(feed) and os.path.getsize(feed) > MAX_FEED_BYTES: + open(feed, "w").close() + # Append so concurrent Claude Code sessions do not clobber each other; + # a short line written in append mode lands intact. + with open(feed, "a") as handle: + handle.write(json.dumps(record, separators=(",", ":")) + "\n") + except Exception: + pass # A feed we cannot write is not worth breaking a session over. + + +def main(): + feed = sys.argv[1] if len(sys.argv) > 1 else "" + try: + payload = json.load(sys.stdin) + except Exception: + print("lumbridge · unreadable status payload") + return 0 + if not isinstance(payload, dict): + print("lumbridge · unreadable status payload") + return 0 + record = extract(payload) + append(feed, record) + print(status_line(record)) + return 0 + + +try: + sys.exit(main()) +except Exception: + # Last resort: never let a status line take a session down. + print("lumbridge") + sys.exit(0) diff --git a/scripts/claude-statusline-bridge.sh b/scripts/claude-statusline-bridge.sh new file mode 100755 index 0000000..f3ddd55 --- /dev/null +++ b/scripts/claude-statusline-bridge.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Lumbridge status-line bridge for Claude Code. +# +# Claude Code 2.1.80+ pipes a JSON payload to the configured `statusLine` +# command on every turn. That payload carries `rate_limits.five_hour` and +# `rate_limits.seven_day` — the real subscription windows, relayed from +# rate-limit headers the CLI already received. Reading them here costs nothing +# and needs no credential, which is why Lumbridge takes this route rather than +# calling the account usage endpoint. +# +# This wrapper exists so a missing python3 degrades to a printed message +# instead of a broken status line. It `exec`s the parser so stdin stays the +# payload. +# +# Install with scripts/install-claude-statusline.sh; uninstall by removing the +# statusLine block it adds to settings.json. + +set -u + +FEED="${LUMBRIDGE_CLAUDE_FEED:-${XDG_DATA_HOME:-$HOME/.local/share}/lumbridge/claude-rate-limits.jsonl}" +HERE=$(dirname "$0") + +if ! command -v python3 >/dev/null 2>&1; then + # No parser available. Say so rather than printing a number we cannot read. + echo "lumbridge · python3 not found" + exit 0 +fi + +mkdir -p "$(dirname "$FEED")" 2>/dev/null || true + +exec python3 "$HERE/claude-statusline-bridge.py" "$FEED" diff --git a/scripts/install-claude-statusline.sh b/scripts/install-claude-statusline.sh new file mode 100755 index 0000000..f875ab9 --- /dev/null +++ b/scripts/install-claude-statusline.sh @@ -0,0 +1,113 @@ +#!/bin/sh +# Installs the Lumbridge status-line bridge into Claude Code's settings. +# +# This is the one thing Lumbridge cannot do without touching the user's +# configuration, so it is a separate, explicit, reversible step rather than +# something a probe does on your behalf. +# +# It sets `statusLine` in $CLAUDE_CONFIG_DIR/settings.json (default +# ~/.claude/settings.json) to run scripts/claude-statusline-bridge.sh. Claude +# Code then pipes its rate-limit payload there on every turn, which is how +# Lumbridge learns the five-hour and seven-day subscription windows without +# ever reading a credential. +# +# install: scripts/install-claude-statusline.sh +# uninstall: scripts/install-claude-statusline.sh --uninstall +# preview: scripts/install-claude-statusline.sh --dry-run +# +# An existing statusLine is backed up and reported, never silently replaced. + +set -eu + +MODE=install +case "${1:-}" in + --uninstall) MODE=uninstall ;; + --dry-run) MODE=dry-run ;; + "") ;; + *) echo "usage: $0 [--uninstall|--dry-run]" >&2; exit 2 ;; +esac + +CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +SETTINGS="$CONFIG_DIR/settings.json" +HERE=$(cd "$(dirname "$0")" && pwd) +BRIDGE="$HERE/claude-statusline-bridge.sh" + +if [ ! -x "$BRIDGE" ]; then + echo "bridge not executable: $BRIDGE" >&2 + exit 1 +fi +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required to edit settings.json safely" >&2 + exit 1 +fi + +python3 - "$SETTINGS" "$BRIDGE" "$MODE" <<'PYTHON' +import json +import os +import shutil +import sys +import time + +settings_path, bridge, mode = sys.argv[1], sys.argv[2], sys.argv[3] +desired = {"type": "command", "command": bridge} + +settings = {} +if os.path.exists(settings_path): + try: + with open(settings_path) as handle: + settings = json.load(handle) + except Exception as error: + print(f"refusing to edit unreadable settings: {error}", file=sys.stderr) + raise SystemExit(1) + if not isinstance(settings, dict): + print("refusing to edit: settings.json is not an object", file=sys.stderr) + raise SystemExit(1) + +current = settings.get("statusLine") + +if mode == "uninstall": + if isinstance(current, dict) and current.get("command") == bridge: + settings.pop("statusLine", None) + action = "removed the Lumbridge status line" + else: + print("no Lumbridge status line installed; nothing to do") + raise SystemExit(0) +elif isinstance(current, dict) and current.get("command") == bridge: + print("Lumbridge status line already installed; nothing to do") + raise SystemExit(0) +else: + if current is not None: + # Someone else's status line is here. Report it and stop rather than + # replacing a thing the user set up. + print("a different statusLine is already configured:", file=sys.stderr) + print(f" {json.dumps(current)}", file=sys.stderr) + print( + "remove it first, or merge the bridge into it by hand:\n" + f" {bridge}", + file=sys.stderr, + ) + raise SystemExit(1) + settings["statusLine"] = desired + action = "installed the Lumbridge status line" + +if mode == "dry-run": + print("would write:") + print(json.dumps({"statusLine": settings.get("statusLine")}, indent=2)) + raise SystemExit(0) + +os.makedirs(os.path.dirname(settings_path), exist_ok=True) +if os.path.exists(settings_path): + backup = f"{settings_path}.lumbridge-backup-{int(time.time())}" + shutil.copy2(settings_path, backup) + print(f"backed up existing settings to {backup}") + +# Write via a temporary file so an interrupted run cannot leave settings.json +# truncated — this file configures the user's editor session. +temporary = f"{settings_path}.lumbridge-tmp" +with open(temporary, "w") as handle: + json.dump(settings, handle, indent=2) + handle.write("\n") +os.replace(temporary, settings_path) +print(f"{action} in {settings_path}") +print("restart or start a Claude Code session for it to take effect") +PYTHON diff --git a/scripts/open-metal-lumbridge.sh b/scripts/open-metal-lumbridge.sh index f54c45c..c1dfb63 100755 --- a/scripts/open-metal-lumbridge.sh +++ b/scripts/open-metal-lumbridge.sh @@ -44,11 +44,31 @@ if [[ ! -x "$binary_path" ]]; then cargo build --locked --manifest-path "$manifest_path" fi -existing_id=$(wmctrl -l | awk -v title="$window_title" 'index($0, title) { print $1; exit }') +# GPUI's X11 client sets WM_NAME but not _NET_WM_NAME, so wmctrl's window list +# reports its title as N/A and a title match never succeeds. xdotool reads +# WM_NAME, and matching the launched PID avoids picking up another client that +# merely mentions the window title. +window_for_pid() { + local pid=$1 candidate + for candidate in $(xdotool search --name 'GPUI workspace' 2>/dev/null); do + if [[ "$(xdotool getwindowpid "$candidate" 2>/dev/null || true)" == "$pid" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +existing_id='' +app_pid=$(pgrep -f "^$binary_path$" | head -n 1 || true) +if [[ -n "$app_pid" ]]; then + existing_id=$(window_for_pid "$app_pid" || true) +fi if [[ -z "$existing_id" ]]; then "$binary_path" >"$XDG_RUNTIME_DIR/lumbridge-gpui.log" 2>&1 & + app_pid=$! for _attempt in $(seq 1 80); do - existing_id=$(wmctrl -l | awk -v title="$window_title" 'index($0, title) { print $1; exit }') + existing_id=$(window_for_pid "$app_pid" || true) [[ -n "$existing_id" ]] && break sleep 0.1 done @@ -59,23 +79,43 @@ if [[ -z "$existing_id" ]]; then exit 1 fi -# Gigabyte G34WQC is 3440x1440 at X=0. Lumbridge owns its left half; Bacon -# remains visible beside it on the right half of the same monitor. -bacon_id=$(wmctrl -l | awk -v title="$bacon_title" 'index($0, title) { print $1; exit }') -if [[ -n "$bacon_id" ]]; then - wmctrl -i -r "$bacon_id" -b remove,maximized_vert,maximized_horz - # GNOME's X11 move coordinate is scaled on this display even though window - # sizes are not; 860 places the frame at physical X=1720. - wmctrl -i -r "$bacon_id" -e 0,860,0,1720,1400 +# Gigabyte G34WQC is 3440x1440 at X=0. Lumbridge owns the whole panel: the +# workspace is a wall of agents and every pixel of width buys another column. +# Set LUMBRIDGE_HALF=1 to fall back to the left half beside Bacon. +if [[ "${LUMBRIDGE_HALF:-0}" == "1" ]]; then + bacon_id=$(wmctrl -l | awk -v title="$bacon_title" 'index($0, title) { print $1; exit }') + if [[ -n "$bacon_id" ]]; then + wmctrl -i -r "$bacon_id" -b remove,maximized_vert,maximized_horz + # GNOME's X11 move coordinate is scaled on this display even though window + # sizes are not; 860 places the frame at physical X=1720. + wmctrl -i -r "$bacon_id" -e 0,860,0,1720,1400 + fi + wmctrl -i -r "$existing_id" -b remove,fullscreen + wmctrl -i -r "$existing_id" -b remove,maximized_vert,maximized_horz + wmctrl -i -r "$existing_id" -e 0,0,0,1720,1400 + wmctrl -i -a "$existing_id" + # GPUI's undecorated X11 client ignores direct position requests under GNOME. + # Use the window manager's move interaction to settle it into the left half. + xdotool key alt+F7 + sleep 0.15 + xdotool mousemove 860 660 click 1 + echo "Lumbridge is on the left half of the Gigabyte display (window $existing_id)." + exit 0 fi +# Fullscreen drops the title bar and the shell owns all 3440x1440. Move it onto +# the Gigabyte first: a fullscreen request applies to whichever output the +# window is currently on, so placing it after would fullscreen the wrong panel. +wmctrl -i -r "$existing_id" -b remove,fullscreen wmctrl -i -r "$existing_id" -b remove,maximized_vert,maximized_horz wmctrl -i -r "$existing_id" -e 0,0,0,1720,1400 wmctrl -i -a "$existing_id" -# GPUI's undecorated X11 client ignores direct position requests under GNOME. -# Use the window manager's move interaction to settle it into the left half. xdotool key alt+F7 sleep 0.15 xdotool mousemove 860 660 click 1 +sleep 0.2 +wmctrl -i -r "$existing_id" -b add,fullscreen +sleep 0.3 -echo "Lumbridge is visible in window $existing_id on the left half of the Gigabyte display." +geometry=$(xdotool getwindowgeometry "$existing_id" | awk '/Geometry/ { print $2 }') +echo "Lumbridge is fullscreen on the Gigabyte display at ${geometry} (window $existing_id)." diff --git a/spikes/gpui-shell/Cargo.lock b/spikes/gpui-shell/Cargo.lock index 084275d..5b9747e 100644 --- a/spikes/gpui-shell/Cargo.lock +++ b/spikes/gpui-shell/Cargo.lock @@ -3043,6 +3043,17 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "lumbridge-harness" +version = "0.0.1" +dependencies = [ + "lumbridge-core", + "nix 0.28.0", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "lumbridge-pty" version = "0.0.1" @@ -3065,6 +3076,8 @@ name = "lumbridge-spike-gpui" version = "0.0.1" dependencies = [ "gpui", + "lumbridge-core", + "lumbridge-harness", "lumbridge-runtime", "lumbridge-spike-model", "lumbridge-storage", diff --git a/spikes/gpui-shell/Cargo.toml b/spikes/gpui-shell/Cargo.toml index 205b8dc..9a25250 100644 --- a/spikes/gpui-shell/Cargo.toml +++ b/spikes/gpui-shell/Cargo.toml @@ -9,6 +9,8 @@ publish = false [dependencies] gpui = "0.2.2" +lumbridge-core = { path = "../../crates/lumbridge-core" } +lumbridge-harness = { path = "../../crates/lumbridge-harness" } lumbridge-runtime = { path = "../../crates/lumbridge-runtime" } lumbridge-spike-model = { path = "../ui-shell-model" } lumbridge-storage = { path = "../../crates/lumbridge-storage" } diff --git a/spikes/gpui-shell/src/main.rs b/spikes/gpui-shell/src/main.rs index 5ec7159..fe59a03 100644 --- a/spikes/gpui-shell/src/main.rs +++ b/spikes/gpui-shell/src/main.rs @@ -1,4 +1,5 @@ mod panel_registry; +mod usage_feed; use std::collections::{BTreeMap, VecDeque}; use std::path::PathBuf; @@ -8,13 +9,14 @@ use gpui::{ App, Application, Bounds, Context, FocusHandle, FontWeight, KeyBinding, KeyDownEvent, Pixels, Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, size, }; +use lumbridge_core::UsageProvenance; use lumbridge_runtime::{ CommandConfig, PtyOptions, RuntimeActorError, RuntimeActorOptions, RuntimeCommand, RuntimeEvent, RuntimeRegistry, RuntimeRegistryError, TerminalSize, }; use lumbridge_spike_model::{ - ActionOutcome, FOOTER_RIGHT, OutputSource, PaneId as FixturePaneId, PaneStatus, ShellAction, - ShellModel, SurfaceKind, WORKSPACES, + ActionOutcome, OutputSource, PaneId as FixturePaneId, PaneStatus, ShellAction, ShellModel, + SurfaceKind, WORKSPACES, }; use lumbridge_storage::Store; use lumbridge_terminal::{ @@ -24,6 +26,7 @@ use lumbridge_terminal::{ }; use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane}; +use usage_feed::{UsageFeed, UsageSegment}; const BG: u32 = 0x090c12; const PANEL: u32 = 0x101620; @@ -43,7 +46,7 @@ const WORKSPACE_SNAPSHOT_ID: &str = "lumbridge-code-gpui-spike"; const SIDEBAR_WIDTH: f32 = 248.0; const APP_HEADER_HEIGHT: f32 = 48.0; const TAB_BAR_HEIGHT: f32 = 38.0; -const APP_FOOTER_HEIGHT: f32 = 32.0; +const APP_FOOTER_HEIGHT: f32 = 46.0; const TERMINAL_CONTENT_VERTICAL_INSET: f32 = 24.0; const TERMINAL_HORIZONTAL_INSET: f32 = 32.0; const TERMINAL_CELL_WIDTH: f32 = 8.4; @@ -84,10 +87,90 @@ struct LumbridgeShell { live_terminals: BTreeMap, store: Option, persistence_status: String, + usage: UsageFeed, + /// Which surface tab each panel is showing. A panel is absent until the + /// user picks something other than the surface it provides natively. + surfaces: BTreeMap, + /// Which decision-shelf choice each panel has selected. Selecting one is + /// inert by design: it prepares nothing and runs nothing. + shelf_choice: BTreeMap, + active_worktree: usize, add_panel_chooser_open: bool, root_focus: FocusHandle, } +/// The surfaces a pane can be viewed through. +/// +/// A pane is the durable unit of work and its surface is a view over that work. +/// Switching one never launches a process, moves the pane, or changes which +/// agent owns the session — it only changes what is drawn. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SurfaceTab { + Terminal, + Browser, + Tools, + Context, + Goal, + Review, +} + +impl SurfaceTab { + const ALL: [Self; 6] = [ + Self::Terminal, + Self::Browser, + Self::Tools, + Self::Context, + Self::Goal, + Self::Review, + ]; + + const fn label(self) -> &'static str { + match self { + Self::Terminal => "TERMINAL", + Self::Browser => "BROWSER", + Self::Tools => "TOOLS", + Self::Context => "CONTEXT", + Self::Goal => "GOAL", + Self::Review => "REVIEW", + } + } + + const fn ordinal(self) -> u64 { + match self { + Self::Terminal => 0, + Self::Browser => 1, + Self::Tools => 2, + Self::Context => 3, + Self::Goal => 4, + Self::Review => 5, + } + } + + /// The one surface a pane of this kind actually provides. + const fn native_for(kind: SurfaceKind) -> Self { + match kind { + SurfaceKind::Terminal => Self::Terminal, + SurfaceKind::Browser => Self::Browser, + SurfaceKind::Markdown => Self::Context, + SurfaceKind::Review => Self::Review, + } + } + + /// Why this pane cannot show this surface. Stated plainly, because the + /// product boundary says an unsupported surface is shown as unavailable + /// rather than simulated. + const fn unavailable_reason(self) -> &'static str { + match self { + Self::Terminal => "Only a terminal pane owns a live shell.", + Self::Browser => "No isolated web engine is wired yet.", + Self::Tools => "Tool calls arrive with the ACP client.", + Self::Context => "Context projection is not built yet.", + Self::Goal => "Goal tracking is not built yet.", + Self::Review => "Review is not wired to Git yet.", + } + } +} + #[derive(Clone)] struct PanelView { id: PanelId, @@ -488,6 +571,8 @@ impl LumbridgeShell { if this .update(cx, |shell, cx| { shell.dispatch(ShellAction::SyntheticStreamTick); + shell.usage.advance(); + shell.usage.tick(); cx.notify(); }) .is_err() @@ -554,6 +639,10 @@ impl LumbridgeShell { live_terminals, store, persistence_status, + usage: UsageFeed::start(), + surfaces: BTreeMap::new(), + shelf_choice: BTreeMap::new(), + active_worktree: 0, add_panel_chooser_open: false, root_focus, } @@ -778,6 +867,54 @@ impl LumbridgeShell { cx.notify(); } + /// Changes which surface a pane is viewed through. + /// + /// This is a view change only. It never touches the PTY, the agent, or the + /// pane's execution target, so a pane keeps running whatever it was running. + fn select_surface( + &mut self, + pane: PanelId, + tab: SurfaceTab, + window: &mut Window, + cx: &mut Context, + ) { + self.timing.mark_dispatch(); + self.surfaces.insert(pane, tab); + if self.panels.select(pane) { + self.persist_panels(); + } + window.focus(&self.root_focus); + cx.notify(); + } + + /// Records which suggestion the user picked. Deliberately does nothing else. + fn select_shelf_choice( + &mut self, + pane: PanelId, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + self.timing.mark_dispatch(); + if self.shelf_choice.get(&pane) == Some(&index) { + self.shelf_choice.remove(&pane); + } else { + self.shelf_choice.insert(pane, index); + } + if self.panels.select(pane) { + self.persist_panels(); + } + window.focus(&self.root_focus); + cx.notify(); + } + + fn select_worktree(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + self.timing.mark_dispatch(); + self.active_worktree = index.min(WORKSPACES.len().saturating_sub(1)); + window.focus(&self.root_focus); + cx.notify(); + } + fn select_pane_at(&mut self, index: usize, window: &mut Window, cx: &mut Context) { self.timing.mark_dispatch(); if self.panels.select_attached_at(index) { @@ -1215,26 +1352,38 @@ impl LumbridgeShell { } }, ); - let surface = match pane.kind { - SurfaceKind::Terminal => "TERMINAL", - SurfaceKind::Markdown => "CONTEXT", - SurfaceKind::Browser => "BROWSER", - SurfaceKind::Review => "REVIEW", - }; - let tabs = ["TERMINAL", "BROWSER", "TOOLS", "CONTEXT", "GOAL", "REVIEW"] + let native = SurfaceTab::native_for(pane.kind); + let shown = self.surface_for(pane.id, pane.kind); + let tabs = SurfaceTab::ALL .into_iter() - .map(|label| { + .map(|tab| { + let active = tab == shown; + let provided = tab == native; div() + .id(("surface-tab", pane_id.get() * 16 + tab.ordinal())) + .cursor_pointer() .h_full() .flex() .items_center() .px_3() .text_xs() - .text_color(rgb(if label == surface { ACCENT } else { MUTED })) - .when(label == surface, |view| { - view.border_b_2().border_color(rgb(ACCENT)) - }) - .child(label) + // Provided but unselected reads as available; unprovided + // stays dim, so the tab strip shows what this pane can do + // before you click rather than after. + .text_color(rgb(if active { + ACCENT + } else if provided { + TEXT + } else { + MUTED + })) + .when(active, |view| view.border_b_2().border_color(rgb(ACCENT))) + .hover(|view| view.bg(rgb(PANEL_ACTIVE)).text_color(rgb(TEXT))) + .child(tab.label()) + .on_click(cx.listener(move |shell, _, window, cx| { + cx.stop_propagation(); + shell.select_surface(pane_id, tab, window, cx); + })) }) .collect::>(); @@ -1304,6 +1453,12 @@ impl LumbridgeShell { .border_color(rgb(BORDER)) .text_xs() .text_color(rgb(if can_detach { MUTED } else { BORDER })) + .when(can_detach, |view| { + view.hover(|view| { + view.border_color(rgb(ATTENTION)) + .text_color(rgb(ATTENTION)) + }) + }) .child(if can_detach { "− DETACH" } else { @@ -1347,7 +1502,67 @@ impl LumbridgeShell { .into_any_element() } + /// The surface a panel is currently being viewed through. + fn surface_for(&self, id: PanelId, kind: SurfaceKind) -> SurfaceTab { + self.surfaces + .get(&id) + .copied() + .unwrap_or_else(|| SurfaceTab::native_for(kind)) + } + + /// What a pane shows when asked for a surface it does not provide. + /// + /// It says so, and it says what is still running underneath, because the + /// pane's process identity does not change when the view does. + fn unavailable_surface(&self, pane: &PanelView, shown: SurfaceTab) -> gpui::AnyElement { + let native = SurfaceTab::native_for(pane.kind); + div() + .flex() + .flex_col() + .size_full() + .min_w_0() + .min_h_0() + .overflow_hidden() + .bg(rgb(BG)) + .border_y_1() + .border_color(rgb(BORDER)) + .child( + div() + .flex() + .flex_col() + .gap_2() + .p_4() + .child( + div() + .text_sm() + .text_color(rgb(TEXT)) + .child(format!("{} unavailable", shown.label())), + ) + .child( + div() + .text_xs() + .text_color(rgb(MUTED)) + .child(shown.unavailable_reason()), + ) + .child( + div() + .mt_2() + .text_xs() + .text_color(rgb(SUCCESS)) + .child(format!( + "{} is still this pane's live surface. Nothing was stopped.", + native.label() + )), + ), + ) + .into_any_element() + } + fn pane_work_surface(&self, pane: &PanelView) -> gpui::AnyElement { + let shown = self.surface_for(pane.id, pane.kind); + if shown != SurfaceTab::native_for(pane.kind) { + return self.unavailable_surface(pane, shown); + } let external = pane.output_source == OutputSource::External; let content = if external { self.terminal_view(pane.id) @@ -1387,35 +1602,22 @@ impl LumbridgeShell { .into_any_element() } - fn pane_decision_region(&self, pane: &PanelView) -> gpui::AnyElement { - let choice = |label: &'static str, detail: &'static str, attention: bool| { - div() - .flex() - .items_center() - .justify_between() - .min_w_0() - .px_2() - .py_1() - .rounded(px(4.0)) - .border_1() - .border_color(rgb(if attention { ATTENTION } else { BORDER })) - .bg(rgb(PANEL_ALT)) - .text_xs() - .text_color(rgb(TEXT)) - .child(label) - .child(div().truncate().text_color(rgb(MUTED)).child(detail)) - }; - let choices = if pane.needs_input() { + fn pane_decision_region(&self, pane: &PanelView, cx: &mut Context) -> gpui::AnyElement { + let pane_id = pane.id; + let picked = self.shelf_choice.get(&pane_id).copied(); + // Every choice names the capability it would need. None of them holds + // that capability: choosing prepares, the command plane executes. + let choices: [(&'static str, &'static str, &'static str, bool); 3] = if pane.needs_input() { [ - ("Review request", "inspect scope", true), - ("Steer…", "edit response", false), - ("Dismiss", "leave inert", false), + ("Review request", "inspect scope", "needs Observe", true), + ("Steer…", "edit response", "needs Execute", false), + ("Dismiss", "leave inert", "inert", false), ] } else { [ - ("Continue", "keep moving", false), - ("Review plan", "inspect commands", false), - ("Ask…", "refine prompt", false), + ("Continue", "keep moving", "needs Execute", false), + ("Review plan", "inspect commands", "needs Observe", false), + ("Ask…", "refine prompt", "inert", false), ] }; div() @@ -1447,8 +1649,55 @@ impl LumbridgeShell { ), ) .child(div().flex().flex_col().gap_1().mt_2().children( - choices.map(|(label, detail, attention)| choice(label, detail, attention)), + choices.into_iter().enumerate().map( + |(index, (label, detail, _capability, attention))| { + let chosen = picked == Some(index); + div() + .id(("shelf-choice", pane_id.get() * 8 + index as u64)) + .cursor_pointer() + .flex() + .items_center() + .justify_between() + .min_w_0() + .px_2() + .py_1() + .rounded(px(4.0)) + .border_1() + .border_color(rgb(if chosen { + ACCENT + } else if attention { + ATTENTION + } else { + BORDER + })) + .bg(rgb(if chosen { PANEL_ACTIVE } else { PANEL_ALT })) + .hover(|view| view.border_color(rgb(ACCENT))) + .text_xs() + .text_color(rgb(TEXT)) + .child(label) + .child(div().truncate().text_color(rgb(MUTED)).child(detail)) + .on_click(cx.listener(move |shell, _, window, cx| { + cx.stop_propagation(); + shell.select_shelf_choice(pane_id, index, window, cx); + })) + }, + ), )) + .child( + div() + .mt_2() + .text_xs() + .text_color(rgb(if picked.is_some() { ACCENT } else { MUTED })) + .child(picked.map_or_else( + || "Pick one to see what it would need".to_owned(), + |index| { + format!( + "{} · {} · nothing has run", + choices[index].0, choices[index].2 + ) + }, + )), + ) .into_any_element() } @@ -1461,6 +1710,7 @@ impl LumbridgeShell { let pane_id = pane.id; div() .id(("workspace-panel", pane_id.get())) + .cursor_pointer() .on_click(cx.listener(move |shell, _, window, cx| { shell.select_pane(pane_id, window, cx); })) @@ -1494,7 +1744,7 @@ impl LumbridgeShell { .flex_none() .min_h_0() .overflow_hidden() - .child(self.pane_decision_region(pane)), + .child(self.pane_decision_region(pane, cx)), ) .into_any_element() } @@ -1795,6 +2045,209 @@ impl LumbridgeShell { ), ) } + + /// Attached panels whose agent is blocked on a human answer. + /// + /// The count is derived rather than declared: a sidebar that claims one + /// pane needs input while no pane is blocked is the same class of untruth + /// as an invented usage number. + fn attention_panels(&self) -> Vec { + self.panels + .attached_ids() + .into_iter() + .map(|id| self.panel_view(id)) + .filter(PanelView::needs_input) + .collect() + } + + /// Where sessions are owned, and what each host is actually doing. + fn runtime_rows(&self) -> Vec { + let running = self + .live_terminals + .values() + .filter(|terminal| matches!(terminal.status, LiveRuntimeStatus::Running { .. })) + .count(); + let total = self.live_terminals.len(); + let local = RuntimeRow { + label: "metal", + detail: format!("{running}/{total} live PTYs"), + tone: if running == 0 { MUTED } else { SUCCESS }, + }; + // Remote runtimes are a Phase 1 deliverable. Until the SSH stdio + // transport exists, the only honest status for a saved host is that + // nothing is connected to it. + let remote = RuntimeRow { + label: "amd-server", + detail: "saved host · no runtime connected".to_owned(), + tone: MUTED, + }; + // The usage adapter is neither a host nor an endpoint, but its health + // belongs beside them: it is the reason the footer can or cannot answer. + let adapter = RuntimeRow { + label: "usage adapter", + detail: format!( + "{} · {}/{} profiles reporting", + self.usage.probe_status(), + self.usage.reporting_profile_count(), + self.usage.declared_profile_count() + ), + tone: if self.usage.reporting_profile_count() == 0 { + MUTED + } else { + SUCCESS + }, + }; + vec![local, remote, adapter] + } + + /// The standing of every harness, always, regardless of what is selected. + /// + /// The old footer answered only for the selected pane, so the moment you + /// focused a plain shell your quota vanished. This strip keeps every + /// harness on screen and leads with what is left, because that is the + /// question. The selected harness expands with its rate and its trust. + fn footer_usage_zone(&self) -> impl IntoElement { + let active = self + .panels + .panel(self.panels.selected()) + .and_then(|panel| self.usage.profile_for_seed(panel.seed)) + .cloned(); + let segments = self.usage.strip(); + let orphan = active.is_none(); + div() + .flex() + .items_center() + .gap_4() + .when(orphan, |view| { + view.child( + div() + .flex_none() + .text_color(rgb(MUTED)) + .child("no harness on this pane"), + ) + }) + .children(segments.into_iter().map(|segment| { + let selected = active.as_ref() == Some(&segment.id); + usage_segment(segment, selected) + })) + } +} + +/// The provenance of a value is carried by the colour of its meter, so the +/// strip reads at a glance without a legend on every segment. +const fn provenance_color(provenance: UsageProvenance) -> u32 { + match provenance { + UsageProvenance::ProviderReported => SUCCESS, + UsageProvenance::HarnessReported => ACCENT, + UsageProvenance::LocallyMeasured => TEXT, + UsageProvenance::Estimated => ATTENTION, + UsageProvenance::Unavailable => MUTED, + } +} + +/// A quota meter. The filled part is spent; the empty part is what is left, +/// which is the way round an engineer reads it. +fn usage_meter(consumed_permille: Option, color: u32) -> impl IntoElement { + let Some(permille) = consumed_permille else { + // No capsule. A full-length empty gauge reads as "plenty left" from + // across the room, which is the opposite of the truth. A hairline is + // visibly not a measurement. + return div() + .w(px(80.0)) + .h(px(2.0)) + .flex_none() + .bg(rgb(BORDER)) + .into_any_element(); + }; + let clamped = u16::try_from(permille.min(1_000)).unwrap_or(1_000); + div() + .w(px(80.0)) + .h(px(8.0)) + .flex_none() + .rounded(px(4.0)) + .bg(rgb(BORDER_QUIET)) + .overflow_hidden() + .child( + div() + .h_full() + .w(relative(f32::from(clamped) / 1_000.0)) + .bg(rgb(color)), + ) + .into_any_element() +} + +fn usage_segment(segment: UsageSegment, selected: bool) -> impl IntoElement { + let color = provenance_color(segment.provenance); + let name_color = if selected { TEXT } else { MUTED }; + div() + .flex() + .items_center() + .gap_2() + .when(selected, |view| { + view.px_2().py_1().rounded(px(4.0)).bg(rgb(PANEL_ACTIVE)) + }) + .child( + div() + .flex_none() + .text_color(rgb(name_color)) + .child(segment.label), + ) + .child(usage_meter(segment.consumed_permille, color)) + .child( + div() + .flex_none() + .text_color(rgb(if segment.headline.is_some() { + TEXT + } else { + MUTED + })) + .child(segment.headline.unwrap_or_else(|| "no reading".to_owned())), + ) + .when_some(segment.reset, |view, reset| { + view.child(div().flex_none().text_color(rgb(MUTED)).child(reset)) + }) + // Only the harness you are looking at spends footer width on its rate + // and its trust. The rest stay one glance wide. + .when(selected, |view| { + view.child(footer_separator()) + .child( + div() + .flex_none() + .text_color(rgb(provenance_color(segment.burn_provenance))) + .child(segment.burn), + ) + .child(provenance_chip(&segment.trust, segment.provenance)) + }) +} + +/// One host or endpoint row in the sidebar's runtime list. +struct RuntimeRow { + label: &'static str, + detail: String, + tone: u32, +} + +fn footer_separator() -> impl IntoElement { + div().text_color(rgb(BORDER)).child("│") +} + +/// The trust marker. Its colour is the provenance, never the value. +fn provenance_chip(text: &str, provenance: UsageProvenance) -> impl IntoElement { + let color = match provenance { + UsageProvenance::ProviderReported => SUCCESS, + UsageProvenance::HarnessReported => ACCENT, + UsageProvenance::LocallyMeasured => TEXT, + UsageProvenance::Estimated => ATTENTION, + UsageProvenance::Unavailable => MUTED, + }; + div() + .px_2() + .py(px(1.0)) + .rounded(px(3.0)) + .border_1() + .border_color(rgb(color)) + .text_color(rgb(color)) + .child(text.to_owned()) } impl Render for LumbridgeShell { @@ -1827,6 +2280,7 @@ impl Render for LumbridgeShell { "{running_runtime_count}/{} LIVE PTYS", self.live_terminals.len() ); + let attention_panels = self.attention_panels(); let sidebar = div() .flex() .flex_col() @@ -1841,11 +2295,30 @@ impl Render for LumbridgeShell { .pt_4() .pb_2() .text_xs() - .text_color(rgb(ATTENTION)) - .child("ATTENTION · 1"), + .text_color(rgb(if attention_panels.is_empty() { + MUTED + } else { + ATTENTION + })) + .child(format!("ATTENTION · {}", attention_panels.len())), ) - .child( + .when(attention_panels.is_empty(), |view| { + view.child( + div() + .mx_2() + .mb_3() + .px_3() + .py_2() + .text_xs() + .text_color(rgb(MUTED)) + .child("No pane is waiting on you"), + ) + }) + .children(attention_panels.into_iter().map(|pane| { + let id = pane.id; div() + .id(("attention-panel", id.get())) + .cursor_pointer() .mx_2() .mb_3() .px_3() @@ -1854,20 +2327,19 @@ impl Render for LumbridgeShell { .bg(rgb(PANEL_ACTIVE)) .border_1() .border_color(rgb(ATTENTION)) - .child( - div() - .text_sm() - .text_color(rgb(TEXT)) - .child("Claude Code · UI"), - ) + .hover(|view| view.bg(rgb(PANEL_ALT))) + .child(div().text_sm().text_color(rgb(TEXT)).child(pane.title)) .child( div() .mt_1() .text_xs() .text_color(rgb(MUTED)) - .child("Waiting for a split decision"), - ), - ) + .child(pane.target), + ) + .on_click(cx.listener(move |shell, _, window, cx| { + shell.select_pane(id, window, cx); + })) + })) .child( div() .px_4() @@ -1877,18 +2349,20 @@ impl Render for LumbridgeShell { .child("WORKTREES"), ) .children(WORKSPACES.into_iter().enumerate().map(|(index, name)| { + let active = index == self.active_worktree; div() + .id(("worktree", index as u64)) + .cursor_pointer() .mx_2() .mb_1() .px_3() .py_2() .rounded(px(5.0)) - .when(index == 0, |view| { - view.bg(rgb(PANEL_ALT)).text_color(rgb(TEXT)) - }) - .when(index != 0, |view| view.text_color(rgb(MUTED))) + .when(active, |view| view.bg(rgb(PANEL_ALT)).text_color(rgb(TEXT))) + .when(!active, |view| view.text_color(rgb(MUTED))) + .hover(|view| view.bg(rgb(PANEL_ACTIVE)).text_color(rgb(TEXT))) .child(name) - .when(index == 0, |view| { + .when(active, |view| { view.child( div() .mt_1() @@ -1897,6 +2371,9 @@ impl Render for LumbridgeShell { .child("main · metal"), ) }) + .on_click(cx.listener(move |shell, _, window, cx| { + shell.select_worktree(index, window, cx); + })) })) .child( div() @@ -1907,30 +2384,13 @@ impl Render for LumbridgeShell { .text_color(rgb(MUTED)) .child("RUNTIMES"), ) - .child( + .children(self.runtime_rows().into_iter().map(|row| { div() .px_4() .py_1() - .text_sm() - .text_color(rgb(SUCCESS)) - .child(format!("metal · {runtime_summary}")), - ) - .child( - div() - .px_4() - .py_1() - .text_sm() - .text_color(rgb(MUTED)) - .child("amd-server · connected"), - ) - .child( - div() - .px_4() - .py_1() - .text_sm() - .text_color(rgb(MUTED)) - .child("spark-1 · sleeping"), - ) + .child(div().text_sm().text_color(rgb(row.tone)).child(row.label)) + .child(div().text_xs().text_color(rgb(MUTED)).child(row.detail)) + })) .child( div() .px_4() @@ -1959,6 +2419,7 @@ impl Render for LumbridgeShell { .rounded(px(4.0)) .border_1() .border_color(rgb(BORDER_QUIET)) + .hover(|view| view.border_color(rgb(ACCENT)).text_color(rgb(TEXT))) .text_xs() .text_color(rgb(MUTED)) .child(format!("↪ {title}")) @@ -1994,6 +2455,8 @@ impl Render for LumbridgeShell { .bg(rgb(PANEL)) .border_b_1() .border_color(rgb(BORDER_QUIET)) + // One tab, because there is one workspace. Two more used to sit + // here looking like navigation and doing nothing. .child( div() .h_full() @@ -2003,21 +2466,7 @@ impl Render for LumbridgeShell { .border_b_2() .border_color(rgb(ACCENT)) .text_sm() - .child("Agent workspace"), - ) - .child( - div() - .px_3() - .text_sm() - .text_color(rgb(MUTED)) - .child("Architecture.md"), - ) - .child( - div() - .px_3() - .text_sm() - .text_color(rgb(MUTED)) - .child("Review"), + .child(WORKSPACES[self.active_worktree]), ) .child(div().flex_1()) .child(div().px_3().text_xs().text_color(rgb(MUTED)).child(format!( @@ -2145,7 +2594,7 @@ impl Render for LumbridgeShell { .ml_3() .text_sm() .text_color(rgb(MUTED)) - .child("Lumbridge Code / main"), + .child(format!("{} / main", WORKSPACES[self.active_worktree])), ) .child(div().flex_1()) .child( @@ -2180,17 +2629,26 @@ impl Render for LumbridgeShell { .flex() .items_center() .justify_between() - .h(px(32.0)) + .h(px(APP_FOOTER_HEIGHT)) .flex_none() .px_3() + .gap_4() .bg(rgb(PANEL_ALT)) .border_t_1() .border_color(rgb(BORDER_QUIET)) .text_xs() .text_color(rgb(MUTED)) - .child(footer_left) - .child(timing) - .child(FOOTER_RIGHT), + .child( + div() + .flex() + .flex_col() + .flex_none() + .min_w_0() + .child(div().truncate().child(footer_left)) + .child(div().truncate().child(timing)), + ) + .child(div().flex_1()) + .child(self.footer_usage_zone()), ) .when(self.model.command_palette().is_open(), |view| { view.child(self.command_palette()) diff --git a/spikes/gpui-shell/src/usage_feed.rs b/spikes/gpui-shell/src/usage_feed.rs new file mode 100644 index 0000000..4739365 --- /dev/null +++ b/spikes/gpui-shell/src/usage_feed.rs @@ -0,0 +1,555 @@ +//! Footer usage for the spike, fed by real probes through the real ledger. +//! +//! Profiles are *declared*; numbers are not. A profile exists as soon as the +//! shell knows a pane is running a harness, so the footer can name the account +//! it cannot read. Observations only ever come from a [`UsageProbe`]. A harness +//! with no adapter yet therefore renders the honest-gap path under its own +//! name — "Claude Code · Anthropic · … usage unavailable" — rather than +//! borrowing another pane's number or showing a zero. +//! +//! The only adapter that exists today is the Codex quota probe. It is started +//! at launch unless `LUMBRIDGE_CODEX_PROBE=0` is set, and if the `codex` binary +//! is absent the probe simply fails to start and its profiles keep rendering as +//! unavailable. + +use std::collections::BTreeMap; + +use lumbridge_core::{ + AccountProfile, AccountProfileId, FooterUsage, UsageLedger, UsageProjection, UsageProvenance, + format_duration_ms, +}; +use lumbridge_harness::claude::{ClaudeCodeProbe, ClaudeCodeProbeOptions}; +use lumbridge_harness::codex::{CodexProbe, CodexProbeOptions}; +use lumbridge_harness::{MonotonicWallClock, ProbeHealth, UsageProbe}; + +use crate::panel_registry::SeedPane; + +/// Harnesses the seeded panes run, whether or not an adapter exists for them. +struct DeclaredProfile { + seed: SeedPane, + id: &'static str, + harness: &'static str, + provider: &'static str, + model: &'static str, + account: &'static str, +} + +/// The Codex pane maps onto the probe's own primary-window profile, so a live +/// reading lands under the pane that produced it. The rest are declarations +/// with no adapter behind them yet. +const DECLARED: &[DeclaredProfile] = &[ + DeclaredProfile { + seed: SeedPane::CodexRuntime, + id: "codex-app-server-primary", + harness: "Codex", + provider: "ChatGPT", + model: "primary window", + account: "subscription", + }, + // The Claude pane maps to the five-hour window, not the token total: it is + // the limit that actually stops work, and it is what "how much do I have + // left" means. The transcript total is still declared by the probe and + // still shows in the strip once it reports. + DeclaredProfile { + seed: SeedPane::ClaudeUi, + id: "claude-code-five-hour", + harness: "Claude Code", + provider: "Anthropic", + model: "five-hour window", + account: "subscription", + }, + DeclaredProfile { + seed: SeedPane::PiDocs, + id: "spark-1-local", + harness: "Pi", + provider: "spark-1", + model: "laguna-s-2.1", + account: "self-hosted", + }, +]; + +pub(crate) struct UsageFeed { + ledger: UsageLedger, + clock: MonotonicWallClock, + profiles: BTreeMap, + seeds: Vec<(SeedPane, AccountProfileId)>, + probes: Vec>, + health: BTreeMap, + probe_status: String, +} + +impl UsageFeed { + pub(crate) fn start() -> Self { + let mut profiles = BTreeMap::new(); + let mut seeds = Vec::new(); + for declared in DECLARED { + let Ok(profile) = AccountProfile::new( + declared.id, + declared.harness, + declared.provider, + declared.model, + declared.account, + ) else { + continue; + }; + seeds.push((declared.seed, profile.id().clone())); + profiles.insert(profile.id().clone(), profile); + } + + let mut feed = Self { + ledger: UsageLedger::new(), + clock: MonotonicWallClock::start(), + profiles, + seeds, + probes: Vec::new(), + health: BTreeMap::new(), + probe_status: "no usage adapter running".to_owned(), + }; + feed.start_codex_probe(); + feed.start_claude_probe(); + feed + } + + /// Starts the Codex quota probe unless the user opted out. + /// + /// A failure here is not an error state for the shell: the profiles stay + /// declared and render as unavailable, which is exactly what they should do + /// when no adapter can run. + fn start_codex_probe(&mut self) { + if std::env::var_os("LUMBRIDGE_CODEX_PROBE").is_some_and(|value| value == "0") { + self.probe_status = "codex probe disabled".to_owned(); + return; + } + match CodexProbe::start(CodexProbeOptions::default()) { + Ok(probe) => { + for profile in probe.profiles() { + self.profiles + .entry(profile.id().clone()) + .or_insert_with(|| profile.clone()); + self.health + .insert(profile.id().clone(), ProbeHealth::Starting); + } + self.probes.push(Box::new(probe)); + self.probe_status = "codex probe starting".to_owned(); + } + Err(error) => { + self.probe_status = format!("codex probe unavailable · {error}"); + } + } + } + + /// Starts the Claude Code transcript probe unless the user opted out. + /// + /// This one reads files rather than launching anything, so it has no + /// harness to fail to find — a missing projects directory simply reports + /// nothing until Claude Code creates it. + fn start_claude_probe(&mut self) { + if std::env::var_os("LUMBRIDGE_CLAUDE_PROBE").is_some_and(|value| value == "0") { + return; + } + match ClaudeCodeProbe::start(ClaudeCodeProbeOptions::default()) { + Ok(probe) => { + for profile in probe.profiles() { + self.profiles + .entry(profile.id().clone()) + .or_insert_with(|| profile.clone()); + self.health + .insert(profile.id().clone(), ProbeHealth::Starting); + } + self.probes.push(Box::new(probe)); + } + Err(error) => { + self.probe_status = format!("claude probe unavailable · {error}"); + } + } + } + + /// Which declared profile a seeded panel belongs to. + /// + /// Panels created at runtime are plain shells with no harness, so they map + /// to nothing rather than borrowing another pane's account. + pub(crate) fn profile_for_seed(&self, seed: Option) -> Option<&AccountProfileId> { + let seed = seed?; + self.seeds + .iter() + .find(|(candidate, _)| *candidate == seed) + .map(|(_, id)| id) + } + + /// Drains every probe into the ledger. Returns whether anything changed. + pub(crate) fn tick(&mut self) -> bool { + let mut changed = false; + for probe in &mut self.probes { + let outcome = probe.poll(); + let health = outcome.health(); + for profile in probe.profiles() { + if self.health.insert(profile.id().clone(), health) != Some(health) { + changed = true; + } + } + for observation in outcome.into_observations() { + // A rejected observation is a real signal, not a crash: the + // ledger refuses anything that would move a profile's stream + // backwards. Dropping it preserves the append-only invariant. + if self.ledger.record(observation).is_ok() { + changed = true; + } + } + } + if changed { + self.probe_status = self.summarize_health(); + } + changed + } + + fn summarize_health(&self) -> String { + if self.probes.is_empty() { + return "no usage adapter running".to_owned(); + } + let ready = self + .health + .values() + .filter(|health| matches!(health, ProbeHealth::Ready)) + .count(); + let faulted = self + .health + .values() + .filter(|health| health.is_faulted()) + .count(); + if faulted > 0 { + return format!("{} probes · {faulted} faulted", self.probes.len()); + } + format!("{} probes · {ready} ready", self.probes.len()) + } + + pub(crate) fn probe_status(&self) -> &str { + &self.probe_status + } + + fn projection(&self, id: &AccountProfileId) -> UsageProjection { + self.ledger.project(id, self.clock.last_emitted_ms()) + } + + /// Advances the feed's read clock. Kept separate from [`Self::tick`] so + /// rendering never mutates the clock mid-frame. + pub(crate) fn advance(&mut self) { + let _ = self.clock.now_ms(); + } + + /// Every harness the footer should account for, in a stable order. + /// + /// The seeded panes come first so the strip does not reorder itself as + /// readings arrive, then any probe profile that has actually reported. A + /// probe profile with nothing to say — Codex's secondary window on an + /// account that has none — is left out rather than shown as an empty rail, + /// because the strip is for harnesses the user is running, not for every + /// row a probe could theoretically fill. + pub(crate) fn strip(&self) -> Vec { + let mut ordered: Vec<&AccountProfileId> = self.seeds.iter().map(|(_, id)| id).collect(); + for id in self.profiles.keys() { + if !ordered.contains(&id) && self.projection(id).is_available() { + ordered.push(id); + } + } + let mut segments: Vec = ordered + .into_iter() + .filter_map(|id| self.segment(id)) + .collect(); + // Two windows on one account would otherwise both read "CODEX". A + // strip that names two different quotas the same thing is worse than + // a longer label. + let duplicated: Vec = segments + .iter() + .filter(|segment| { + segments + .iter() + .filter(|other| other.label == segment.label) + .count() + > 1 + }) + .map(|segment| segment.label.clone()) + .collect(); + for segment in &mut segments { + if duplicated.contains(&segment.label) { + segment.label = format!("{} {}", segment.label, segment.model.to_uppercase()); + } + } + segments + } + + fn segment(&self, id: &AccountProfileId) -> Option { + let profile = self.profiles.get(id)?; + let projection = self.projection(id); + let footer = FooterUsage::new(profile, &projection); + let consumed_permille = projection.consumed_permille(); + Some(UsageSegment { + id: id.clone(), + label: profile.harness().to_uppercase(), + model: profile.model().to_owned(), + consumed_permille, + // "How much do I have left" is the question an engineer actually + // asks. Consumption stays available in the expanded detail. + // With a ceiling, lead with what is left. Without one, the honest + // headline is what was spent — a profile that reports real + // consumption but no quota must not read as "no reading". + // + // Show a decimal only when the value actually has one. Codex + // reports whole percents, so "81.0% left" would claim a tenth of a + // percent of resolution that no one measured. + headline: consumed_permille + .map(|permille| { + let left = 1_000_u64.saturating_sub(permille); + if left % 10 == 0 { + format!("{}% left", left / 10) + } else { + format!("{}.{}% left", left / 10, left % 10) + } + }) + .or_else(|| { + let consumed = projection.consumed()?; + let unit = projection.unit()?; + Some(format!("{} used", unit.format_amount(consumed))) + }), + // A profile that reports spend but no ceiling and no reset should + // say so where the reset would go, rather than leaving a silent + // gap that reads as "we just haven't shown it yet". + reset: projection.resets_in_ms().map_or_else( + || { + (projection.is_available() && projection.limit().is_none()) + .then(|| "no quota reported".to_owned()) + }, + |remaining| Some(format!("resets {}", format_duration_ms(remaining))), + ), + burn: footer.burn(), + burn_provenance: projection.burn_provenance(), + trust: footer.trust(), + provenance: projection.provenance(), + }) + } + + /// Profiles that have produced at least one usable reading. + pub(crate) fn reporting_profile_count(&self) -> usize { + self.profiles + .keys() + .filter(|id| self.projection(id).is_available()) + .count() + } + + pub(crate) fn declared_profile_count(&self) -> usize { + self.profiles.len() + } + + pub(crate) fn shutdown(&mut self) { + for probe in &mut self.probes { + probe.shutdown(); + } + self.probes.clear(); + } +} + +impl Drop for UsageFeed { + fn drop(&mut self) { + self.shutdown(); + } +} + +/// One harness's standing in the footer strip. +/// +/// `remaining` and `reset` are `None` when there is nothing to report. That is +/// deliberately not an empty string: the renderer has to decide what a gap +/// looks like rather than printing a blank where a number belongs. +pub(crate) struct UsageSegment { + pub(crate) id: AccountProfileId, + pub(crate) label: String, + pub(crate) model: String, + pub(crate) consumed_permille: Option, + /// What to lead with: how much is left when a ceiling is known, how much + /// was spent when it is not, and nothing at all when there is no reading. + pub(crate) headline: Option, + pub(crate) reset: Option, + pub(crate) burn: String, + pub(crate) burn_provenance: UsageProvenance, + pub(crate) trust: String, + pub(crate) provenance: UsageProvenance, +} + +#[cfg(test)] +mod tests { + use super::{DECLARED, UsageFeed}; + use crate::panel_registry::SeedPane; + use lumbridge_core::UsageProvenance; + + /// Never starts a probe, so the test cannot depend on an installed harness, + /// on the user's transcripts, or on a status-line bridge being present. + fn declared_only_feed() -> UsageFeed { + // SAFETY-FREE: this only sets environment variables for this process. + unsafe { + std::env::set_var("LUMBRIDGE_CODEX_PROBE", "0"); + std::env::set_var("LUMBRIDGE_CLAUDE_PROBE", "0"); + } + UsageFeed::start() + } + + #[test] + fn a_declared_harness_with_no_adapter_shows_a_gap_under_its_own_name() { + let feed = declared_only_feed(); + let segment = feed + .strip() + .into_iter() + .find(|segment| segment.label.starts_with("CLAUDE CODE")) + .expect("Claude Code is declared"); + assert!( + segment.headline.is_none(), + "a harness with no adapter must not show a number" + ); + assert_eq!(segment.trust, "no usage source"); + } + + #[test] + fn panels_without_a_seed_have_no_profile() { + let feed = declared_only_feed(); + assert!(feed.profile_for_seed(None).is_none()); + assert!( + feed.profile_for_seed(Some(SeedPane::Architecture)) + .is_none() + ); + } + + #[test] + fn nothing_reports_until_a_probe_produces_a_reading() { + let feed = declared_only_feed(); + assert_eq!(feed.declared_profile_count(), DECLARED.len()); + assert_eq!( + feed.reporting_profile_count(), + 0, + "declaring a profile must not imply a reading" + ); + } + + #[test] + fn the_codex_pane_maps_onto_the_probe_profile_id() { + let feed = declared_only_feed(); + let id = feed + .profile_for_seed(Some(SeedPane::CodexRuntime)) + .expect("the Codex pane declares a profile"); + assert_eq!( + id.as_str(), + "codex-app-server-primary", + "a live reading must land under the pane that produced it" + ); + } + + #[test] + fn the_strip_lists_every_declared_harness_even_with_no_readings() { + let feed = declared_only_feed(); + let strip = feed.strip(); + assert_eq!(strip.len(), DECLARED.len()); + let labels: Vec<&str> = strip.iter().map(|segment| segment.label.as_str()).collect(); + assert_eq!(labels, ["CODEX", "CLAUDE CODE", "PI"]); + for segment in &strip { + assert!( + segment.headline.is_none() && segment.reset.is_none(), + "a harness with no adapter must report no figure at all" + ); + assert!(!segment.provenance.is_available()); + assert_eq!(segment.provenance, UsageProvenance::Unavailable); + } + } + + #[test] + fn a_fractional_reading_keeps_its_decimal() { + use lumbridge_core::{UsageObservation, UsageUnit, UsageWindow}; + + let mut feed = declared_only_feed(); + let id = feed + .profile_for_seed(Some(SeedPane::CodexRuntime)) + .expect("declared") + .clone(); + feed.advance(); + let now_ms = feed.clock.last_emitted_ms(); + feed.ledger + .record( + UsageObservation::counted( + id.clone(), + UsageUnit::WindowPermille, + 185, + UsageProvenance::ProviderReported, + now_ms.saturating_sub(1_000), + ) + .expect("available") + .with_limit(1_000) + .expect("a positive limit") + .with_window(UsageWindow::until(now_ms + 3_600_000)) + .expect("inside the window"), + ) + .expect("ordered"); + let segment = feed + .strip() + .into_iter() + .find(|segment| segment.id == id) + .expect("present"); + assert_eq!(segment.headline.as_deref(), Some("81.5% left")); + } + + #[test] + fn a_segment_reports_what_is_left_not_what_was_used() { + use lumbridge_core::{UsageObservation, UsageUnit, UsageWindow}; + + let mut feed = declared_only_feed(); + let id = feed + .profile_for_seed(Some(SeedPane::CodexRuntime)) + .expect("the Codex pane declares a profile") + .clone(); + // The feed reads a real wall clock, so the fixture has to sit inside a + // window that is still open now rather than at an arbitrary epoch. + feed.advance(); + let now_ms = feed.clock.last_emitted_ms(); + feed.ledger + .record( + UsageObservation::counted( + id.clone(), + UsageUnit::WindowPermille, + 180, + UsageProvenance::ProviderReported, + now_ms.saturating_sub(1_000), + ) + .expect("available") + .with_limit(1_000) + .expect("a positive limit") + .with_window(UsageWindow::until(now_ms + 3_600_000)) + .expect("inside the window"), + ) + .expect("ordered"); + + let segment = feed + .strip() + .into_iter() + .find(|segment| segment.id == id) + .expect("the Codex segment is present"); + assert_eq!( + segment.headline.as_deref(), + Some("82% left"), + "a whole-percent source must not render a tenth it never measured" + ); + assert_eq!(segment.consumed_permille, Some(180)); + assert_eq!( + segment.reset.as_deref(), + Some("resets 1h 0m"), + "a windowed reading reports its reset" + ); + } + + #[test] + fn a_segment_with_no_reading_offers_no_figure_to_render() { + let feed = declared_only_feed(); + for segment in feed.strip() { + assert!(segment.headline.is_none()); + assert!(segment.reset.is_none()); + assert!( + !segment.burn.contains("/hr"), + "a rate needs readings this segment does not have" + ); + assert_eq!(segment.consumed_permille, None); + } + } +} diff --git a/spikes/ui-shell-model/src/lib.rs b/spikes/ui-shell-model/src/lib.rs index 6d290e9..9ab81f1 100644 --- a/spikes/ui-shell-model/src/lib.rs +++ b/spikes/ui-shell-model/src/lib.rs @@ -205,8 +205,17 @@ pub const WORKSPACES: [&str; 5] = [ "ACP adapters", "Usage telemetry", ]; +/// Static footer strings retained only by the Floem comparison shell. +/// +/// These are layout fixtures, not usage. They are not a usage source and must +/// not be rendered as one: the GPUI shell now derives its footer from +/// `lumbridge_core::UsageLedger`, where every value carries a provenance and a +/// missing fact renders as missing. Delete these once Floem consumes the ledger +/// or the candidate is retired. See decision 0012. pub const FOOTER_LEFT: &str = "6 panes · 3 remote · 1 needs input"; +/// See [`FOOTER_LEFT`]: a layout fixture, not a usage reading. pub const FOOTER_CENTER: &str = "Codex · ChatGPT subscription · 62% window remaining"; +/// See [`FOOTER_LEFT`]: a layout fixture, not a usage reading. pub const FOOTER_RIGHT: &str = "burn 8.4%/hr · resets in 2h 14m"; #[derive(Clone, Debug, Eq, PartialEq)]